From a47f5236ef651dd8eaeb344fd83c7ef82f9730ac Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 16 Mar 2022 10:12:13 +0100 Subject: [PATCH] feat: Full rework of account management Complete rewrite of the account management and related systems. Makes the architecture more modular, allowing for easier extensions and configurations. --- .componentsignore | 2 + RELEASE_NOTES.md | 26 +- config/app/README.md | 5 +- config/app/init/base/init.json | 4 +- config/app/init/initialize-intro.json | 22 + config/app/init/initializers/seeded-pod.json | 23 - config/app/init/initializers/seeding.json | 14 + config/app/variables/cli/cli.json | 4 +- config/app/variables/resolver/resolver.json | 4 +- config/default.json | 4 +- config/dynamic.json | 2 +- config/example-https-file.json | 2 +- config/file-acp.json | 2 +- config/file-root.json | 2 +- config/file.json | 2 +- .../http/notifications/base/description.json | 5 +- .../http/notifications/webhooks/routes.json | 3 +- config/https-file-cli.json | 2 +- config/identity/README.md | 18 +- config/identity/access/initializers/idp.json | 2 +- config/identity/email/default.json | 3 +- .../handler/account-store/default.json | 35 -- .../handler/adapter-factory/webid.json | 5 +- config/identity/handler/default.json | 36 +- .../identity/handler/interaction/routes.json | 52 -- .../handler/interaction/routes/consent.json | 21 - .../interaction/routes/credentials.json | 53 -- .../interaction/routes/forgot-password.json | 28 - .../handler/interaction/routes/index.json | 21 - .../handler/interaction/routes/login.json | 21 - .../handler/interaction/routes/prompt.json | 30 - .../interaction/routes/reset-password.json | 21 - .../handler/interaction/views/controls.json | 31 - .../handler/interaction/views/html.json | 45 -- .../handler/provider-factory/identity.json | 16 +- config/identity/handler/storage/default.json | 78 +++ config/identity/handler/storage/password.json | 35 ++ config/identity/interaction/default.json | 16 + .../identity/interaction/enable/account.json | 56 ++ .../enable/client-credentials.json | 43 ++ .../identity/interaction/enable/password.json | 52 ++ config/identity/interaction/enable/pod.json | 44 ++ config/identity/interaction/enable/webid.json | 44 ++ config/identity/interaction/no-accounts.json | 15 + config/identity/interaction/no-pods.json | 15 + .../interaction/routing/account/create.json | 37 ++ .../interaction/routing/account/login.json | 14 + .../interaction/routing/account/logout.json | 40 ++ .../interaction/routing/account/main.json | 42 ++ .../interaction/routing/account/resource.json | 60 ++ .../routing/client-credentials/create.json | 25 + .../routing/client-credentials/resource.json | 45 ++ .../interaction/routing/core/index.json | 49 ++ .../interaction/routing/core/login.json | 72 +++ .../interaction/routing/core/main.json | 38 ++ .../identity/interaction/routing/default.json | 85 +++ .../interaction/routing/oidc/cancel.json | 44 ++ .../interaction/routing/oidc/consent.json | 66 +++ .../routing/oidc/forget-webid.json | 47 ++ .../interaction/routing/oidc/main.json | 35 ++ .../interaction/routing/oidc/pick-webid.json | 44 ++ .../interaction/routing/oidc/prompt.json | 51 ++ .../interaction/routing/password/create.json | 62 ++ .../interaction/routing/password/forgot.json | 68 +++ .../interaction/routing/password/login.json | 63 ++ .../interaction/routing/password/main.json | 11 + .../interaction/routing/password/reset.json | 51 ++ .../routing/password/resource.json | 55 ++ .../interaction/routing/pod/create.json | 30 + .../interaction/routing/pod/resource.json | 11 + .../interaction/routing/views/html.json | 45 ++ .../interaction/routing/webid/link.json | 29 + .../interaction/routing/webid/resource.json | 33 ++ config/identity/ownership/token.json | 2 +- .../identity/ownership/unsafe-no-check.json | 2 +- config/identity/registration/disabled.json | 8 - config/identity/registration/enabled.json | 35 -- .../registration/route/registration.json | 29 - .../ldp/authorization/readers/ownership.json | 3 +- config/ldp/metadata-parser/default.json | 4 + .../parsers/authorization.json | 16 + .../ldp/metadata-parser/parsers/cookie.json | 21 + config/ldp/metadata-writer/default.json | 2 + .../ldp/metadata-writer/writers/cookie.json | 17 + config/memory-subdomains.json | 4 +- config/path-routing.json | 2 +- config/quota-file.json | 2 +- config/restrict-idp.json | 2 +- config/sparql-endpoint-root.json | 2 +- config/sparql-endpoint.json | 2 +- config/sparql-file-storage.json | 2 +- config/util/variables/default.json | 2 +- documentation/markdown/README.md | 2 +- .../features/accounts/controls.md | 27 + .../features/accounts/overview.md | 58 ++ .../architecture/features/accounts/routes.md | 126 ++++ .../architecture/features/http-handler.md | 3 +- .../features/protocol/overview.md | 2 +- .../markdown/usage/account/json-api.md | 281 +++++++++ .../markdown/usage/account/login-method.md | 118 ++++ .../markdown/usage/account/migration.md | 60 ++ .../markdown/usage/identity-provider.md | 150 ++--- documentation/markdown/usage/seeding-pods.md | 37 +- .../markdown/usage/starting-server.md | 2 +- documentation/mkdocs.yml | 11 +- package-lock.json | 144 ++++- package.json | 11 +- src/authorization/OwnerPermissionReader.ts | 38 +- .../input/metadata/AuthorizationParser.ts | 43 ++ src/http/input/metadata/CookieParser.ts | 36 ++ .../output/metadata/CookieMetadataWriter.ts | 50 ++ src/identity/IdentityProviderHttpHandler.ts | 51 +- src/identity/IdentityUtil.ts | 21 + .../configuration/AccountPromptFactory.ts | 91 +++ .../configuration/IdentityProviderFactory.ts | 80 +-- src/identity/configuration/PromptFactory.ts | 7 + .../interaction/BaseInteractionHandler.ts | 51 -- src/identity/interaction/ConsentHandler.ts | 151 ----- src/identity/interaction/ControlHandler.ts | 133 ++++- .../interaction/CookieInteractionHandler.ts | 70 +++ .../interaction/FixedInteractionHandler.ts | 26 - src/identity/interaction/HtmlViewHandler.ts | 52 +- .../interaction/InteractionHandler.ts | 16 +- src/identity/interaction/InteractionUtil.ts | 86 +++ .../interaction/JsonConversionHandler.ts | 72 +++ .../interaction/JsonInteractionHandler.ts | 41 ++ src/identity/interaction/JsonView.ts | 10 + .../interaction/LocationInteractionHandler.ts | 29 +- .../interaction/LockingInteractionHandler.ts | 44 ++ .../interaction/OidcControlHandler.ts | 16 + src/identity/interaction/PromptHandler.ts | 28 - .../interaction/StaticInteractionHandler.ts | 23 + src/identity/interaction/VersionHandler.ts | 28 + .../interaction/ViewInteractionHandler.ts | 40 ++ src/identity/interaction/YupUtil.ts | 68 +++ .../account/AccountDetailsHandler.ts | 29 + .../interaction/account/AccountIdRoute.ts | 21 + .../account/CreateAccountHandler.ts | 27 + .../interaction/account/util/Account.ts | 57 ++ .../interaction/account/util/AccountStore.ts | 28 + .../interaction/account/util/AccountUtil.ts | 93 +++ .../account/util/BaseAccountStore.ts | 69 +++ .../account/util/BaseCookieStore.ts | 40 ++ .../interaction/account/util/CookieStore.ts | 31 + .../ClientCredentialsAdapterFactory.ts | 80 +++ .../ClientCredentialsDetailsHandler.ts | 48 ++ .../CreateClientCredentialsHandler.ts | 52 ++ .../DeleteClientCredentialsHandler.ts | 32 + .../util/BaseClientCredentialsStore.ts | 63 ++ .../util/ClientCredentialsIdRoute.ts | 20 + .../util/ClientCredentialsStore.ts | 47 ++ .../email-password/EmailPasswordUtil.ts | 22 - .../ClientCredentialsAdapterFactory.ts | 56 -- .../credentials/CreateCredentialsHandler.ts | 54 -- .../credentials/CredentialsHandler.ts | 23 - .../credentials/DeleteCredentialsHandler.ts | 47 -- .../credentials/EmailPasswordAuthorizer.ts | 37 -- .../credentials/ListCredentialsHandler.ts | 24 - .../handler/ForgotPasswordHandler.ts | 86 --- .../email-password/handler/LoginHandler.ts | 82 --- .../handler/RegistrationHandler.ts | 46 -- .../handler/ResetPasswordHandler.ts | 57 -- .../email-password/storage/AccountStore.ts | 98 ---- .../storage/BaseAccountStore.ts | 161 ----- .../util/RegistrationManager.ts | 239 -------- .../interaction/login/LogoutHandler.ts | 43 ++ .../interaction/login/ResolveLoginHandler.ts | 114 ++++ .../interaction/oidc/CancelOidcHandler.ts | 23 + .../interaction/oidc/ClientInfoHandler.ts | 52 ++ .../interaction/oidc/ConsentHandler.ts | 104 ++++ .../interaction/oidc/ForgetWebIdHandler.ts | 30 + .../interaction/oidc/PickWebIdHandler.ts | 72 +++ .../interaction/oidc/PromptHandler.ts | 41 ++ .../password/CreatePasswordHandler.ts | 76 +++ .../password/DeletePasswordHandler.ts | 44 ++ .../password/ForgotPasswordHandler.ts | 115 ++++ .../password/PasswordLoginHandler.ts | 52 ++ .../password/ResetPasswordHandler.ts | 62 ++ .../password/UpdatePasswordHandler.ts | 59 ++ .../util/BaseEmailSender.ts | 4 + .../password/util/BaseForgotPasswordStore.ts | 30 + .../password/util/BasePasswordStore.ts | 103 ++++ .../util/EmailSender.ts | 0 .../password/util/ForgotPasswordStore.ts | 27 + .../password/util/PasswordIdRoute.ts | 19 + .../password/util/PasswordStore.ts | 54 ++ .../interaction/pod/CreatePodHandler.ts | 165 ++++++ src/identity/interaction/pod/PodIdRoute.ts | 19 + .../interaction/pod/util/BasePodStore.ts | 48 ++ src/identity/interaction/pod/util/PodStore.ts | 18 + .../routing/AbsolutePathInteractionRoute.ts | 15 +- .../routing/AuthorizedRouteHandler.ts | 43 ++ .../interaction/routing/IdInteractionRoute.ts | 49 ++ .../interaction/routing/InteractionRoute.ts | 38 +- .../routing/InteractionRouteHandler.ts | 28 +- .../routing/RelativePathInteractionRoute.ts | 39 +- .../interaction/webid/LinkWebIdHandler.ts | 100 ++++ .../interaction/webid/UnlinkWebIdHandler.ts | 32 + .../interaction/webid/WebIdLinkRoute.ts | 19 + .../interaction/webid/util/BaseWebIdStore.ts | 74 +++ .../interaction/webid/util/WebIdStore.ts | 31 + src/index.ts | 110 +++- src/init/SeededAccountInitializer.ts | 105 ++++ src/init/SeededPodInitializer.ts | 57 -- src/pods/ConfigPodManager.ts | 13 +- src/pods/GeneratedPodManager.ts | 13 +- src/pods/PodManager.ts | 4 +- src/pods/generate/BaseResourcesGenerator.ts | 10 +- src/pods/generate/GenerateUtil.ts | 9 +- src/pods/generate/PodGenerator.ts | 4 +- src/pods/generate/ResourcesGenerator.ts | 2 +- src/pods/generate/StaticFolderGenerator.ts | 2 +- .../generate/SubfolderResourcesGenerator.ts | 2 +- src/pods/generate/TemplatedPodGenerator.ts | 8 +- .../generate/TemplatedResourcesGenerator.ts | 2 +- src/pods/settings/PodSettings.ts | 12 +- src/util/StringUtil.ts | 16 + src/util/Vocabularies.ts | 3 + src/util/errors/MethodNotAllowedHttpError.ts | 2 +- src/util/map/MapUtil.ts | 3 + templates/identity/account/account.html.ejs | 9 + .../create-client-credentials.html.ejs | 76 +++ .../identity/account/create-pod.html.ejs | 99 ++++ .../identity/account/link-webid.html.ejs | 47 ++ templates/identity/account/resource.html.ejs | 141 +++++ .../identity/email-password/consent.html.ejs | 72 --- .../email-password/forgot-password.html.ejs | 45 -- .../identity/email-password/login.html.ejs | 56 -- .../email-password/register-partial.html.ejs | 168 ------ .../register-response-partial.html.ejs | 54 -- .../identity/email-password/register.html.ejs | 31 - templates/identity/index.html.ejs | 12 + templates/identity/login.html.ejs | 49 ++ templates/identity/oidc/consent.html.ejs | 111 ++++ templates/identity/password/create.html.ejs | 44 ++ templates/identity/password/forgot.html.ejs | 49 ++ templates/identity/password/login.html.ejs | 57 ++ templates/identity/password/register.html.ejs | 67 +++ .../reset-email.html.ejs} | 0 .../reset.html.ejs} | 27 +- templates/identity/password/update.html.ejs | 42 ++ templates/root/intro/acp/.acr | 32 + templates/root/intro/base/.meta | 7 + templates/root/intro/base/index.html | 97 +++ templates/root/intro/wac/.acl | 21 + templates/root/prefilled/base/index.html | 39 +- templates/root/static/index.html | 47 +- templates/scripts/util.js | 143 +++-- templates/styles/main.css | 18 +- test/deploy/createAccountCredentials.ts | 74 ++- test/integration/Accounts.test.ts | 467 +++++++++++++++ test/integration/Config.ts | 2 +- test/integration/DynamicPods.test.ts | 44 +- test/integration/Identity.test.ts | 554 ++++++------------ test/integration/IdentityTestState.ts | 101 ++-- test/integration/Quota.test.ts | 19 +- test/integration/RestrictedIdentity.test.ts | 67 +-- test/integration/SeedingPods.test.ts | 85 ++- test/integration/ServerFetch.test.ts | 122 ++-- test/integration/Subdomains.test.ts | 43 +- test/integration/config/ldp-with-acp.json | 2 +- test/integration/config/ldp-with-auth.json | 2 +- .../integration/config/legacy-websockets.json | 2 +- test/integration/config/permission-table.json | 2 +- test/integration/config/quota-global.json | 2 +- test/integration/config/quota-pod.json | 2 +- test/integration/config/restricted-idp.json | 4 +- .../config/server-dynamic-unsafe.json | 4 +- test/integration/config/server-file.json | 2 +- test/integration/config/server-memory.json | 2 +- .../integration/config/server-middleware.json | 2 +- .../integration/config/server-redis-lock.json | 15 +- .../config/server-subdomains-unsafe.json | 2 +- .../config/server-without-auth.json | 12 +- .../config/webhook-notifications.json | 2 +- .../config/websocket-notifications.json | 2 +- .../OwnerPermissionReader.test.ts | 41 +- .../metadata/AuthorizationParser.test.ts | 34 ++ .../http/input/metadata/CookieParser.test.ts | 29 + .../metadata/CookieMetadataWriter.test.ts | 39 ++ test/unit/identity/ControlHandler.test.ts | 52 -- .../IdentityProviderHttpHandler.test.ts | 57 +- test/unit/identity/IdentityUtil.test.ts | 24 + .../AccountPromptFactory.test.ts | 130 ++++ .../IdentityProviderFactory.test.ts | 77 +-- .../BaseInteractionHandler.test.ts | 70 --- .../interaction/ControlHandler.test.ts | 134 +++++ .../CookieInteractionHandler.test.ts | 161 +++++ .../FixedInteractionHandler.test.ts | 15 - .../interaction/HtmlViewHandler.test.ts | 25 +- .../interaction/InteractionHandler.test.ts | 28 - .../interaction/InteractionUtil.test.ts | 97 +++ .../interaction/JsonConversionHandler.test.ts | 99 ++++ .../LocationInteractionHandler.test.ts | 26 +- .../LockingInteractionHandler.test.ts | 81 +++ .../interaction/OidcControlHandler.test.ts | 17 + .../interaction/PromptHandler.test.ts | 37 -- .../StaticInteractionHandler.test.ts | 10 + .../interaction/VersionHandler.test.ts | 31 + .../ViewInteractionHandler.test.ts | 56 ++ .../unit/identity/interaction/YupUtil.test.ts | 57 ++ .../account/AccountDetailsHandler.test.ts | 20 + .../account/AccountIdRoute.test.ts | 11 + .../account/CreateAccountHandler.test.ts | 23 + .../account/util/AccountUtil.test.ts | 114 ++++ .../account/util/BaseAccountStore.test.ts | 54 ++ .../account/util/BaseCookieStore.test.ts | 59 ++ .../ClientCredentialsAdapterFactory.test.ts | 113 ++++ .../ClientCredentialsDetailsHandler.test.ts | 59 ++ .../ClientCredentialsIdRoute.test.ts | 17 + .../CreateClientCredentialsHandler.test.ts | 64 ++ .../DeleteClientCredentialsHandler.test.ts | 48 ++ .../util/BaseClientCredentialsStore.test.ts | 91 +++ .../email-password/EmailPasswordUtil.test.ts | 16 - .../ClientCredentialsAdapterFactory.test.ts | 74 --- .../CreateCredentialsHandler.test.ts | 91 --- .../DeleteCredentialsHandler.test.ts | 76 --- .../EmailPasswordAuthorizer.test.ts | 57 -- .../ListCredentialsHandler.test.ts | 58 -- .../handler/ForgotPasswordHandler.test.ts | 76 --- .../handler/LoginHandler.test.ts | 81 --- .../handler/RegistrationHandler.test.ts | 54 -- .../handler/ResetPasswordHandler.test.ts | 60 -- .../email-password/handler/Util.ts | 17 - .../storage/BaseAccountStore.test.ts | 139 ----- .../util/RegistrationManager.test.ts | 321 ---------- .../interaction/login/LogoutHandler.test.ts | 46 ++ .../login/ResolveLoginHandler.test.ts | 169 ++++++ .../oidc/CancelOidcHandler.test.ts | 33 ++ .../oidc/ClientInfoHandler.test.ts | 62 ++ .../{ => oidc}/ConsentHandler.test.ts | 84 +-- .../oidc/ForgetWebIdHandler.test.ts | 43 ++ .../interaction/oidc/PickWebIdHandler.test.ts | 103 ++++ .../interaction/oidc/PromptHandler.test.ts | 33 ++ .../password/CreatePasswordHandler.test.ts | 91 +++ .../password/DeletePasswordHandler.test.ts | 82 +++ .../password/ForgotPasswordHandler.test.ts | 89 +++ .../password/PasswordIdRoute.test.ts | 14 + .../password/PasswordLoginHandler.test.ts | 54 ++ .../password/ResetPasswordHandler.test.ts | 64 ++ .../password/UpdatePasswordHandler.test.ts | 67 +++ .../util/BaseEmailSender.test.ts | 11 +- .../util/BaseForgotPasswordStore.test.ts | 41 ++ .../password/util/BasePasswordStore.test.ts | 87 +++ .../interaction/pod/CreatePodHandler.test.ts | 178 ++++++ .../interaction/pod/PodIdRoute.test.ts | 14 + .../interaction/pod/util/BasePodStore.test.ts | 50 ++ .../AbsolutePathInteractionRoute.test.ts | 5 + .../routing/AuthorizedRouteHandler.test.ts | 56 ++ .../routing/IdInteractionRoute.test.ts | 48 ++ .../routing/InteractionRouteHandler.test.ts | 42 +- .../RelativePathInteractionRoute.test.ts | 21 +- .../webid/LinkWebIdHandler.test.ts | 107 ++++ .../webid/UnlinkWebIdHandler.test.ts | 42 ++ .../interaction/webid/WebIdLinkRoute.test.ts | 14 + .../webid/util/BaseWebIdStore.test.ts | 112 ++++ .../init/SeededAccountInitializer.test.ts | 92 +++ test/unit/init/SeededPodInitializer.test.ts | 55 -- test/unit/pods/ConfigPodManager.test.ts | 8 +- test/unit/pods/GeneratedPodManager.test.ts | 10 +- .../generate/TemplatedPodGenerator.test.ts | 18 +- .../WebhookChannel2023Type.test.ts | 2 +- .../WebHookChannel2023/WebhookEmitter.test.ts | 2 +- test/unit/util/StringUtil.test.ts | 15 +- test/util/AccountUtil.ts | 76 +++ test/util/Util.ts | 5 +- 366 files changed, 12345 insertions(+), 5111 deletions(-) create mode 100644 config/app/init/initialize-intro.json delete mode 100644 config/app/init/initializers/seeded-pod.json create mode 100644 config/app/init/initializers/seeding.json delete mode 100644 config/identity/handler/account-store/default.json delete mode 100644 config/identity/handler/interaction/routes.json delete mode 100644 config/identity/handler/interaction/routes/consent.json delete mode 100644 config/identity/handler/interaction/routes/credentials.json delete mode 100644 config/identity/handler/interaction/routes/forgot-password.json delete mode 100644 config/identity/handler/interaction/routes/index.json delete mode 100644 config/identity/handler/interaction/routes/login.json delete mode 100644 config/identity/handler/interaction/routes/prompt.json delete mode 100644 config/identity/handler/interaction/routes/reset-password.json delete mode 100644 config/identity/handler/interaction/views/controls.json delete mode 100644 config/identity/handler/interaction/views/html.json create mode 100644 config/identity/handler/storage/default.json create mode 100644 config/identity/handler/storage/password.json create mode 100644 config/identity/interaction/default.json create mode 100644 config/identity/interaction/enable/account.json create mode 100644 config/identity/interaction/enable/client-credentials.json create mode 100644 config/identity/interaction/enable/password.json create mode 100644 config/identity/interaction/enable/pod.json create mode 100644 config/identity/interaction/enable/webid.json create mode 100644 config/identity/interaction/no-accounts.json create mode 100644 config/identity/interaction/no-pods.json create mode 100644 config/identity/interaction/routing/account/create.json create mode 100644 config/identity/interaction/routing/account/login.json create mode 100644 config/identity/interaction/routing/account/logout.json create mode 100644 config/identity/interaction/routing/account/main.json create mode 100644 config/identity/interaction/routing/account/resource.json create mode 100644 config/identity/interaction/routing/client-credentials/create.json create mode 100644 config/identity/interaction/routing/client-credentials/resource.json create mode 100644 config/identity/interaction/routing/core/index.json create mode 100644 config/identity/interaction/routing/core/login.json create mode 100644 config/identity/interaction/routing/core/main.json create mode 100644 config/identity/interaction/routing/default.json create mode 100644 config/identity/interaction/routing/oidc/cancel.json create mode 100644 config/identity/interaction/routing/oidc/consent.json create mode 100644 config/identity/interaction/routing/oidc/forget-webid.json create mode 100644 config/identity/interaction/routing/oidc/main.json create mode 100644 config/identity/interaction/routing/oidc/pick-webid.json create mode 100644 config/identity/interaction/routing/oidc/prompt.json create mode 100644 config/identity/interaction/routing/password/create.json create mode 100644 config/identity/interaction/routing/password/forgot.json create mode 100644 config/identity/interaction/routing/password/login.json create mode 100644 config/identity/interaction/routing/password/main.json create mode 100644 config/identity/interaction/routing/password/reset.json create mode 100644 config/identity/interaction/routing/password/resource.json create mode 100644 config/identity/interaction/routing/pod/create.json create mode 100644 config/identity/interaction/routing/pod/resource.json create mode 100644 config/identity/interaction/routing/views/html.json create mode 100644 config/identity/interaction/routing/webid/link.json create mode 100644 config/identity/interaction/routing/webid/resource.json delete mode 100644 config/identity/registration/disabled.json delete mode 100644 config/identity/registration/enabled.json delete mode 100644 config/identity/registration/route/registration.json create mode 100644 config/ldp/metadata-parser/parsers/authorization.json create mode 100644 config/ldp/metadata-parser/parsers/cookie.json create mode 100644 config/ldp/metadata-writer/writers/cookie.json create mode 100644 documentation/markdown/architecture/features/accounts/controls.md create mode 100644 documentation/markdown/architecture/features/accounts/overview.md create mode 100644 documentation/markdown/architecture/features/accounts/routes.md create mode 100644 documentation/markdown/usage/account/json-api.md create mode 100644 documentation/markdown/usage/account/login-method.md create mode 100644 documentation/markdown/usage/account/migration.md create mode 100644 src/http/input/metadata/AuthorizationParser.ts create mode 100644 src/http/input/metadata/CookieParser.ts create mode 100644 src/http/output/metadata/CookieMetadataWriter.ts create mode 100644 src/identity/IdentityUtil.ts create mode 100644 src/identity/configuration/AccountPromptFactory.ts create mode 100644 src/identity/configuration/PromptFactory.ts delete mode 100644 src/identity/interaction/BaseInteractionHandler.ts delete mode 100644 src/identity/interaction/ConsentHandler.ts create mode 100644 src/identity/interaction/CookieInteractionHandler.ts delete mode 100644 src/identity/interaction/FixedInteractionHandler.ts create mode 100644 src/identity/interaction/InteractionUtil.ts create mode 100644 src/identity/interaction/JsonConversionHandler.ts create mode 100644 src/identity/interaction/JsonInteractionHandler.ts create mode 100644 src/identity/interaction/JsonView.ts create mode 100644 src/identity/interaction/LockingInteractionHandler.ts create mode 100644 src/identity/interaction/OidcControlHandler.ts delete mode 100644 src/identity/interaction/PromptHandler.ts create mode 100644 src/identity/interaction/StaticInteractionHandler.ts create mode 100644 src/identity/interaction/VersionHandler.ts create mode 100644 src/identity/interaction/ViewInteractionHandler.ts create mode 100644 src/identity/interaction/YupUtil.ts create mode 100644 src/identity/interaction/account/AccountDetailsHandler.ts create mode 100644 src/identity/interaction/account/AccountIdRoute.ts create mode 100644 src/identity/interaction/account/CreateAccountHandler.ts create mode 100644 src/identity/interaction/account/util/Account.ts create mode 100644 src/identity/interaction/account/util/AccountStore.ts create mode 100644 src/identity/interaction/account/util/AccountUtil.ts create mode 100644 src/identity/interaction/account/util/BaseAccountStore.ts create mode 100644 src/identity/interaction/account/util/BaseCookieStore.ts create mode 100644 src/identity/interaction/account/util/CookieStore.ts create mode 100644 src/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.ts create mode 100644 src/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.ts create mode 100644 src/identity/interaction/client-credentials/CreateClientCredentialsHandler.ts create mode 100644 src/identity/interaction/client-credentials/DeleteClientCredentialsHandler.ts create mode 100644 src/identity/interaction/client-credentials/util/BaseClientCredentialsStore.ts create mode 100644 src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute.ts create mode 100644 src/identity/interaction/client-credentials/util/ClientCredentialsStore.ts delete mode 100644 src/identity/interaction/email-password/EmailPasswordUtil.ts delete mode 100644 src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.ts delete mode 100644 src/identity/interaction/email-password/credentials/CreateCredentialsHandler.ts delete mode 100644 src/identity/interaction/email-password/credentials/CredentialsHandler.ts delete mode 100644 src/identity/interaction/email-password/credentials/DeleteCredentialsHandler.ts delete mode 100644 src/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.ts delete mode 100644 src/identity/interaction/email-password/credentials/ListCredentialsHandler.ts delete mode 100644 src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts delete mode 100644 src/identity/interaction/email-password/handler/LoginHandler.ts delete mode 100644 src/identity/interaction/email-password/handler/RegistrationHandler.ts delete mode 100644 src/identity/interaction/email-password/handler/ResetPasswordHandler.ts delete mode 100644 src/identity/interaction/email-password/storage/AccountStore.ts delete mode 100644 src/identity/interaction/email-password/storage/BaseAccountStore.ts delete mode 100644 src/identity/interaction/email-password/util/RegistrationManager.ts create mode 100644 src/identity/interaction/login/LogoutHandler.ts create mode 100644 src/identity/interaction/login/ResolveLoginHandler.ts create mode 100644 src/identity/interaction/oidc/CancelOidcHandler.ts create mode 100644 src/identity/interaction/oidc/ClientInfoHandler.ts create mode 100644 src/identity/interaction/oidc/ConsentHandler.ts create mode 100644 src/identity/interaction/oidc/ForgetWebIdHandler.ts create mode 100644 src/identity/interaction/oidc/PickWebIdHandler.ts create mode 100644 src/identity/interaction/oidc/PromptHandler.ts create mode 100644 src/identity/interaction/password/CreatePasswordHandler.ts create mode 100644 src/identity/interaction/password/DeletePasswordHandler.ts create mode 100644 src/identity/interaction/password/ForgotPasswordHandler.ts create mode 100644 src/identity/interaction/password/PasswordLoginHandler.ts create mode 100644 src/identity/interaction/password/ResetPasswordHandler.ts create mode 100644 src/identity/interaction/password/UpdatePasswordHandler.ts rename src/identity/interaction/{email-password => password}/util/BaseEmailSender.ts (84%) create mode 100644 src/identity/interaction/password/util/BaseForgotPasswordStore.ts create mode 100644 src/identity/interaction/password/util/BasePasswordStore.ts rename src/identity/interaction/{email-password => password}/util/EmailSender.ts (100%) create mode 100644 src/identity/interaction/password/util/ForgotPasswordStore.ts create mode 100644 src/identity/interaction/password/util/PasswordIdRoute.ts create mode 100644 src/identity/interaction/password/util/PasswordStore.ts create mode 100644 src/identity/interaction/pod/CreatePodHandler.ts create mode 100644 src/identity/interaction/pod/PodIdRoute.ts create mode 100644 src/identity/interaction/pod/util/BasePodStore.ts create mode 100644 src/identity/interaction/pod/util/PodStore.ts create mode 100644 src/identity/interaction/routing/AuthorizedRouteHandler.ts create mode 100644 src/identity/interaction/routing/IdInteractionRoute.ts create mode 100644 src/identity/interaction/webid/LinkWebIdHandler.ts create mode 100644 src/identity/interaction/webid/UnlinkWebIdHandler.ts create mode 100644 src/identity/interaction/webid/WebIdLinkRoute.ts create mode 100644 src/identity/interaction/webid/util/BaseWebIdStore.ts create mode 100644 src/identity/interaction/webid/util/WebIdStore.ts create mode 100644 src/init/SeededAccountInitializer.ts delete mode 100644 src/init/SeededPodInitializer.ts create mode 100644 templates/identity/account/account.html.ejs create mode 100644 templates/identity/account/create-client-credentials.html.ejs create mode 100644 templates/identity/account/create-pod.html.ejs create mode 100644 templates/identity/account/link-webid.html.ejs create mode 100644 templates/identity/account/resource.html.ejs delete mode 100644 templates/identity/email-password/consent.html.ejs delete mode 100644 templates/identity/email-password/forgot-password.html.ejs delete mode 100644 templates/identity/email-password/login.html.ejs delete mode 100644 templates/identity/email-password/register-partial.html.ejs delete mode 100644 templates/identity/email-password/register-response-partial.html.ejs delete mode 100644 templates/identity/email-password/register.html.ejs create mode 100644 templates/identity/index.html.ejs create mode 100644 templates/identity/login.html.ejs create mode 100644 templates/identity/oidc/consent.html.ejs create mode 100644 templates/identity/password/create.html.ejs create mode 100644 templates/identity/password/forgot.html.ejs create mode 100644 templates/identity/password/login.html.ejs create mode 100644 templates/identity/password/register.html.ejs rename templates/identity/{email-password/reset-password-email.html.ejs => password/reset-email.html.ejs} (100%) rename templates/identity/{email-password/reset-password.html.ejs => password/reset.html.ejs} (57%) create mode 100644 templates/identity/password/update.html.ejs create mode 100644 templates/root/intro/acp/.acr create mode 100644 templates/root/intro/base/.meta create mode 100644 templates/root/intro/base/index.html create mode 100644 templates/root/intro/wac/.acl create mode 100644 test/integration/Accounts.test.ts create mode 100644 test/unit/http/input/metadata/AuthorizationParser.test.ts create mode 100644 test/unit/http/input/metadata/CookieParser.test.ts create mode 100644 test/unit/http/output/metadata/CookieMetadataWriter.test.ts delete mode 100644 test/unit/identity/ControlHandler.test.ts create mode 100644 test/unit/identity/IdentityUtil.test.ts create mode 100644 test/unit/identity/configuration/AccountPromptFactory.test.ts delete mode 100644 test/unit/identity/interaction/BaseInteractionHandler.test.ts create mode 100644 test/unit/identity/interaction/ControlHandler.test.ts create mode 100644 test/unit/identity/interaction/CookieInteractionHandler.test.ts delete mode 100644 test/unit/identity/interaction/FixedInteractionHandler.test.ts delete mode 100644 test/unit/identity/interaction/InteractionHandler.test.ts create mode 100644 test/unit/identity/interaction/InteractionUtil.test.ts create mode 100644 test/unit/identity/interaction/JsonConversionHandler.test.ts create mode 100644 test/unit/identity/interaction/LockingInteractionHandler.test.ts create mode 100644 test/unit/identity/interaction/OidcControlHandler.test.ts delete mode 100644 test/unit/identity/interaction/PromptHandler.test.ts create mode 100644 test/unit/identity/interaction/StaticInteractionHandler.test.ts create mode 100644 test/unit/identity/interaction/VersionHandler.test.ts create mode 100644 test/unit/identity/interaction/ViewInteractionHandler.test.ts create mode 100644 test/unit/identity/interaction/YupUtil.test.ts create mode 100644 test/unit/identity/interaction/account/AccountDetailsHandler.test.ts create mode 100644 test/unit/identity/interaction/account/AccountIdRoute.test.ts create mode 100644 test/unit/identity/interaction/account/CreateAccountHandler.test.ts create mode 100644 test/unit/identity/interaction/account/util/AccountUtil.test.ts create mode 100644 test/unit/identity/interaction/account/util/BaseAccountStore.test.ts create mode 100644 test/unit/identity/interaction/account/util/BaseCookieStore.test.ts create mode 100644 test/unit/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.test.ts create mode 100644 test/unit/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.test.ts create mode 100644 test/unit/identity/interaction/client-credentials/ClientCredentialsIdRoute.test.ts create mode 100644 test/unit/identity/interaction/client-credentials/CreateClientCredentialsHandler.test.ts create mode 100644 test/unit/identity/interaction/client-credentials/DeleteClientCredentialsHandler.test.ts create mode 100644 test/unit/identity/interaction/client-credentials/util/BaseClientCredentialsStore.test.ts delete mode 100644 test/unit/identity/interaction/email-password/EmailPasswordUtil.test.ts delete mode 100644 test/unit/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.test.ts delete mode 100644 test/unit/identity/interaction/email-password/credentials/CreateCredentialsHandler.test.ts delete mode 100644 test/unit/identity/interaction/email-password/credentials/DeleteCredentialsHandler.test.ts delete mode 100644 test/unit/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.test.ts delete mode 100644 test/unit/identity/interaction/email-password/credentials/ListCredentialsHandler.test.ts delete mode 100644 test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts delete mode 100644 test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts delete mode 100644 test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts delete mode 100644 test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts delete mode 100644 test/unit/identity/interaction/email-password/handler/Util.ts delete mode 100644 test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts delete mode 100644 test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts create mode 100644 test/unit/identity/interaction/login/LogoutHandler.test.ts create mode 100644 test/unit/identity/interaction/login/ResolveLoginHandler.test.ts create mode 100644 test/unit/identity/interaction/oidc/CancelOidcHandler.test.ts create mode 100644 test/unit/identity/interaction/oidc/ClientInfoHandler.test.ts rename test/unit/identity/interaction/{ => oidc}/ConsentHandler.test.ts (51%) create mode 100644 test/unit/identity/interaction/oidc/ForgetWebIdHandler.test.ts create mode 100644 test/unit/identity/interaction/oidc/PickWebIdHandler.test.ts create mode 100644 test/unit/identity/interaction/oidc/PromptHandler.test.ts create mode 100644 test/unit/identity/interaction/password/CreatePasswordHandler.test.ts create mode 100644 test/unit/identity/interaction/password/DeletePasswordHandler.test.ts create mode 100644 test/unit/identity/interaction/password/ForgotPasswordHandler.test.ts create mode 100644 test/unit/identity/interaction/password/PasswordIdRoute.test.ts create mode 100644 test/unit/identity/interaction/password/PasswordLoginHandler.test.ts create mode 100644 test/unit/identity/interaction/password/ResetPasswordHandler.test.ts create mode 100644 test/unit/identity/interaction/password/UpdatePasswordHandler.test.ts rename test/unit/identity/interaction/{email-password => password}/util/BaseEmailSender.test.ts (86%) create mode 100644 test/unit/identity/interaction/password/util/BaseForgotPasswordStore.test.ts create mode 100644 test/unit/identity/interaction/password/util/BasePasswordStore.test.ts create mode 100644 test/unit/identity/interaction/pod/CreatePodHandler.test.ts create mode 100644 test/unit/identity/interaction/pod/PodIdRoute.test.ts create mode 100644 test/unit/identity/interaction/pod/util/BasePodStore.test.ts create mode 100644 test/unit/identity/interaction/routing/AuthorizedRouteHandler.test.ts create mode 100644 test/unit/identity/interaction/routing/IdInteractionRoute.test.ts create mode 100644 test/unit/identity/interaction/webid/LinkWebIdHandler.test.ts create mode 100644 test/unit/identity/interaction/webid/UnlinkWebIdHandler.test.ts create mode 100644 test/unit/identity/interaction/webid/WebIdLinkRoute.test.ts create mode 100644 test/unit/identity/interaction/webid/util/BaseWebIdStore.test.ts create mode 100644 test/unit/init/SeededAccountInitializer.test.ts delete mode 100644 test/unit/init/SeededPodInitializer.test.ts create mode 100644 test/util/AccountUtil.ts diff --git a/.componentsignore b/.componentsignore index ea3c04a90..fc0c67764 100644 --- a/.componentsignore +++ b/.componentsignore @@ -11,6 +11,7 @@ "ChangeMap", "CredentialSet", "Dict", + "EmptyObject", "Error", "EventEmitter", "FetchDocumentLoader", @@ -21,6 +22,7 @@ "IndexTypeCollection", "IdentifierMap", "IdentifierSetMultiMap", + "interactionPolicy.DefaultPolicy", "NodeJS.Dict", "NotificationChannelType", "PermissionMap", diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5d3d234b6..000d9c104 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -5,13 +5,26 @@ ### New features - The minimum supported Node version is now v18. +- Account management and everything related to it have been drastically changed, + see the [usage documentation](https://communitysolidserver.github.io/CommunitySolidServer/7.x/usage/identity-provider/) + for an overview of the new features, + and the [architecture documentation](http://communitysolidserver.github.io/CommunitySolidServer/7.x/architecture/features/accounts/overview/) + for an overview of the new structure. + Creating an account now requires multiple steps, but allows you to have multiple pods or WebIDs for 1 account. + The architecture has been updated to be more easily extensible. +- Pod seeding has been updated to account for the new account management, with an update CLI parameter `--seedConfig`, + see the [updated documentation](https://communitysolidserver.github.io/CommunitySolidServer/7.x/usage/seeding-pods/) + for more details. +- Due to the changes in account management, setup has been removed completely. + The `*-no-setup.json` configurations have been renamed to `*-root.json` to indicate their focus on the root container. - The `StaticAssetHandler` can now be used to link static pages to containers. This can be used to set a static page for the root container of a server. See the `/config/app/init/static-root.json` config for an example. ### Data migration -No actions are required to migrate data. +Old account data will need to be migrated as described in the +[documentation](https://communitysolidserver.github.io/CommunitySolidServer/7.x/usage/account/migration/). ### Configuration changes @@ -23,16 +36,21 @@ The `@context` needs to be updated to The following changes pertain to the imports in the default configs: - There is a new `static-root.json` import option for `app/init`, setting a static page for the root container. +- There is a new set of imports `identity/interaction` to determine the IDP features. +- There is a new set of imports `storage/location` to determine where the root storage of the server is located. +- The `app/setup`and `identity/registration` imports have been removed. The following changes are relevant for v6 custom configs that replaced certain features. +- All configurations that had a reference to setup have been updated. - `/app/init/*` imports have changed. Functionality remained the same though. - All imports that define storages have been updated with new storage classes. - `/http/notifications/base/storage.json` - - `/identity/*` - `/storage/keyvalue/storages/storages.json` - All identifiers containing the string "WebHook" have been renamed to instead use "Webhook" to be consistent with the notification type. +- `/identity/*` configurations have drastically changed due to the account management update. +- `/http/static/default.json` has been updated to allow easier overriding of the static resources. ### Interface changes @@ -45,6 +63,10 @@ These changes are relevant if you wrote custom modules for the server that depen `HashEncodingPathStorage` has similarly been replaced by introducing `HashEncodingStorage`. - All classes with the name `WebHook*` have been renamed to `Webhook*` to be consistent with the corresponding notification type. +- Most classes related to the IDP have been changed. +- All classes related to setup have been removed. +- The `StaticAssetHandler` has bene updated to support the new functionality. +- `SeededPodInitializer` has been renamed to `SeededAccountInitializer`. ## v6.1.0 diff --git a/config/app/README.md b/config/app/README.md index a47b59f72..89492860e 100644 --- a/config/app/README.md +++ b/config/app/README.md @@ -8,8 +8,9 @@ Contains a list of initializer that need to be run when starting the server. * *default*: The default setup. The ParallelHandler can be used to add custom Initializers. * *initialize-root*: Makes sure the root container has the necessary resources to function properly. - This is only relevant if setup is disabled but root container access is still required. -* *initialize-prefilled-root*: Similar to `initialize-root` but adds some introductory resources to the root container. +* *initialize-prefilled-root*: Similar to `initialize-root` but adds an index page to the root container. +* *initialize-intro*: Similar to `initialize-prefilled-root` but adds an index page + specific to the memory-based server of the default configuration. * *static-root*: Shows a static introduction page at the server root. This is not a Solid resource. ## Main diff --git a/config/app/init/base/init.json b/config/app/init/base/init.json index 76d7bdbae..0729e8306 100644 --- a/config/app/init/base/init.json +++ b/config/app/init/base/init.json @@ -4,7 +4,7 @@ "css:config/app/init/initializers/base-url.json", "css:config/app/init/initializers/logger.json", "css:config/app/init/initializers/server.json", - "css:config/app/init/initializers/seeded-pod.json", + "css:config/app/init/initializers/seeding.json", "css:config/app/init/initializers/version.json", "css:config/app/init/initializers/workers.json" ], @@ -33,7 +33,7 @@ { "@id": "urn:solid-server:default:CleanupInitializer"}, { "@id": "urn:solid-server:default:BaseUrlVerifier" }, { "@id": "urn:solid-server:default:PrimaryParallelInitializer" }, - { "@id": "urn:solid-server:default:SeededPodInitializer" }, + { "@id": "urn:solid-server:default:SeededAccountInitializer" }, { "@id": "urn:solid-server:default:ModuleVersionVerifier" }, { "@id": "urn:solid-server:default:WorkerManager" } ] diff --git a/config/app/init/initialize-intro.json b/config/app/init/initialize-intro.json new file mode 100644 index 000000000..004214967 --- /dev/null +++ b/config/app/init/initialize-intro.json @@ -0,0 +1,22 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "import": [ + "css:config/app/init/default.json", + "css:config/app/init/initializers/root.json" + ], + "@graph": [ + { + "comment": "Initializes the root container resource.", + "@id": "urn:solid-server:default:PrimaryParallelInitializer", + "@type": "ParallelHandler", + "handlers": [ + { "@id": "urn:solid-server:default:RootInitializer" } + ] + }, + { + "@id": "urn:solid-server:default:RootFolderGenerator", + "@type": "StaticFolderGenerator", + "templateFolder": "@css:templates/root/intro" + } + ] +} diff --git a/config/app/init/initializers/seeded-pod.json b/config/app/init/initializers/seeded-pod.json deleted file mode 100644 index a7988863a..000000000 --- a/config/app/init/initializers/seeded-pod.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "Separate manager from the RegistrationHandler in case registration is disabled.", - "@id": "urn:solid-server:default:SeededPodRegistrationManager", - "@type": "RegistrationManager", - "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "args_webIdSuffix": "/profile/card#me", - "args_identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" }, - "args_ownershipValidator": { "@id": "urn:solid-server:auth:password:OwnershipValidator" }, - "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, - "args_podManager": { "@id": "urn:solid-server:default:PodManager" } - }, - { - "comment": "Initializer that instantiates all the seeded accounts and pods.", - "@id": "urn:solid-server:default:SeededPodInitializer", - "@type": "SeededPodInitializer", - "registrationManager": { "@id": "urn:solid-server:default:SeededPodRegistrationManager" }, - "configFilePath": { "@id": "urn:solid-server:default:variable:seededPodConfigJson" } - } - ] -} diff --git a/config/app/init/initializers/seeding.json b/config/app/init/initializers/seeding.json new file mode 100644 index 000000000..fa02a675c --- /dev/null +++ b/config/app/init/initializers/seeding.json @@ -0,0 +1,14 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Initializer that instantiates all the seeded accounts and pods.", + "@id": "urn:solid-server:default:SeededAccountInitializer", + "@type": "SeededAccountInitializer", + "accountHandler": { "@id": "urn:solid-server:default:CreateAccountHandler" }, + "passwordHandler": { "@id": "urn:solid-server:default:CreatePasswordHandler" }, + "podHandler": { "@id": "urn:solid-server:default:CreatePodHandler" }, + "configFilePath": { "@id": "urn:solid-server:default:variable:seedConfig" } + } + ] +} diff --git a/config/app/variables/cli/cli.json b/config/app/variables/cli/cli.json index 47d385f6e..119e2a698 100644 --- a/config/app/variables/cli/cli.json +++ b/config/app/variables/cli/cli.json @@ -105,11 +105,11 @@ }, { "@type": "YargsParameter", - "name": "seededPodConfigJson", + "name": "seedConfig", "options": { "requiresArg": true, "type": "string", - "describe": "Path to the file that will be used to seed pods." + "describe": "Path to the file that will be used to seed accounts and pods." } }, { diff --git a/config/app/variables/resolver/resolver.json b/config/app/variables/resolver/resolver.json index 92fef320a..54e961ab4 100644 --- a/config/app/variables/resolver/resolver.json +++ b/config/app/variables/resolver/resolver.json @@ -68,10 +68,10 @@ } }, { - "CombinedShorthandResolver:_resolvers_key": "urn:solid-server:default:variable:seededPodConfigJson", + "CombinedShorthandResolver:_resolvers_key": "urn:solid-server:default:variable:seedConfig", "CombinedShorthandResolver:_resolvers_value": { "@type": "AssetPathExtractor", - "key": "seededPodConfigJson" + "key": "seedConfig" } }, { diff --git a/config/default.json b/config/default.json index 73034cdae..42da36f12 100644 --- a/config/default.json +++ b/config/default.json @@ -2,7 +2,7 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", "import": [ "css:config/app/main/default.json", - "css:config/app/init/initialize-prefilled-root.json", + "css:config/app/init/initialize-intro.json", "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", @@ -12,9 +12,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/config/dynamic.json b/config/dynamic.json index 84b53a552..9780c7d40 100644 --- a/config/dynamic.json +++ b/config/dynamic.json @@ -12,9 +12,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/dynamic.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/config/example-https-file.json b/config/example-https-file.json index 5ba745e8f..f0c37f0a0 100644 --- a/config/example-https-file.json +++ b/config/example-https-file.json @@ -12,9 +12,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/config/file-acp.json b/config/file-acp.json index 58ecb5a31..efbd5b01c 100644 --- a/config/file-acp.json +++ b/config/file-acp.json @@ -12,9 +12,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/acp.json", "css:config/ldp/handler/default.json", diff --git a/config/file-root.json b/config/file-root.json index 43e50a492..1b8961400 100644 --- a/config/file-root.json +++ b/config/file-root.json @@ -12,9 +12,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/no-accounts.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/disabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/config/file.json b/config/file.json index 14d4fdec4..7a8dde0fb 100644 --- a/config/file.json +++ b/config/file.json @@ -12,9 +12,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/config/http/notifications/base/description.json b/config/http/notifications/base/description.json index 640c414cf..6e1d1fbd8 100644 --- a/config/http/notifications/base/description.json +++ b/config/http/notifications/base/description.json @@ -21,7 +21,10 @@ "comment": "The root URL of all Notification subscription routes.", "@id": "urn:solid-server:default:NotificationRoute", "@type": "RelativePathInteractionRoute", - "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "base": { + "@type": "AbsolutePathInteractionRoute", + "path": { "@id": "urn:solid-server:default:variable:baseUrl" } + }, "relativePath": "/.notifications/" } ] diff --git a/config/http/notifications/webhooks/routes.json b/config/http/notifications/webhooks/routes.json index aefceff16..59fa23aec 100644 --- a/config/http/notifications/webhooks/routes.json +++ b/config/http/notifications/webhooks/routes.json @@ -11,7 +11,8 @@ "@id": "urn:solid-server:default:WebhookWebIdRoute", "@type": "RelativePathInteractionRoute", "base": { "@id": "urn:solid-server:default:WebhookRoute" }, - "relativePath": "/webId" + "relativePath": "/webId", + "ensureSlash": false }, { diff --git a/config/https-file-cli.json b/config/https-file-cli.json index 49bab6b2b..a96ae265c 100644 --- a/config/https-file-cli.json +++ b/config/https-file-cli.json @@ -12,9 +12,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/config/identity/README.md b/config/identity/README.md index 74370d1be..3335355cb 100644 --- a/config/identity/README.md +++ b/config/identity/README.md @@ -27,8 +27,15 @@ Necessary for sending e-mail when using IDP. Contains everything needed for setting up the Identity Provider. -* *default*: As of writing there is not much customization possible. - This contains everything needed. +* *default*: Contains all the core components of the IDP. + +## Interaction + +Everything related to the JSON API and its routing. + +* *default*: Everything enabled. +* *no-accounts*: Disables the creation of new accounts. +* *no-pods*: Disables the creation of new pods. ## Ownership @@ -44,10 +51,3 @@ What to use for pod creation. * *dynamic*: Every created pod has its own Components.js config for its ResourceStore, which can differ from the others. * *static*: All pod data is stored in separate containers in the same ResourceStore. - -## Registration - -If users should be able to register on the server. - -* *enabled*: Enables registration. -* *disabled*: Disables registration. diff --git a/config/identity/access/initializers/idp.json b/config/identity/access/initializers/idp.json index 8c60aeba3..89c58c12e 100644 --- a/config/identity/access/initializers/idp.json +++ b/config/identity/access/initializers/idp.json @@ -11,7 +11,7 @@ "source": { "@type": "ContainerInitializer", "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "args_path": "/idp/", + "args_path": "/.account/", "args_store": { "@id": "urn:solid-server:default:ResourceStore" }, "args_generator": { "@type": "StaticFolderGenerator", diff --git a/config/identity/email/default.json b/config/identity/email/default.json index 1bb7cd14b..888ecfbf8 100644 --- a/config/identity/email/default.json +++ b/config/identity/email/default.json @@ -4,7 +4,8 @@ { "comment": "The default configuration does not contain credentials for an email client. In production systems, you likely want to set up your own.", "@id": "urn:solid-server:default:EmailSender", - "@type": "UnsupportedAsyncHandler" + "@type": "UnsupportedAsyncHandler", + "errorMessage": "No email server is configured." } ] } diff --git a/config/identity/handler/account-store/default.json b/config/identity/handler/account-store/default.json deleted file mode 100644 index 0ae3a0957..000000000 --- a/config/identity/handler/account-store/default.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "The storage adapter that persists usernames, passwords, etc.", - "@id": "urn:solid-server:auth:password:AccountStore", - "@type": "BaseAccountStore", - "saltRounds": 10, - "storage": { - "@type": "Base64EncodingStorage", - "source": { - "@type": "ContainerPathStorage", - "relativePath": "/accounts/", - "source": { "@id": "urn:solid-server:default:KeyValueStorage" } - } - }, - "forgotPasswordStorage": { - "@id": "urn:solid-server:default:ExpiringForgotPasswordStorage" - } - }, - { - "comment": "Stores expiring data. This class has a `finalize` function that needs to be called after stopping the server.", - "@id": "urn:solid-server:default:ExpiringForgotPasswordStorage", - "@type": "WrappedExpiringStorage", - "source": { - "@type": "Base64EncodingStorage", - "source": { - "@type": "ContainerPathStorage", - "relativePath": "/forgot-password/", - "source": { "@id": "urn:solid-server:default:KeyValueStorage" } - } - } - } - ] -} diff --git a/config/identity/handler/adapter-factory/webid.json b/config/identity/handler/adapter-factory/webid.json index 59d378735..b0388b8f0 100644 --- a/config/identity/handler/adapter-factory/webid.json +++ b/config/identity/handler/adapter-factory/webid.json @@ -5,10 +5,11 @@ "comment": "An adapter is responsible for storing all interaction metadata.", "@id": "urn:solid-server:default:IdpAdapterFactory", "@type": "ClientCredentialsAdapterFactory", - "storage": { "@id": "urn:solid-server:auth:password:CredentialsStorage" }, + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "clientCredentialsStore": { "@id": "urn:solid-server:default:ClientCredentialsStore" }, "source": { "@type": "WebIdAdapterFactory", - "converter": {"@id": "urn:solid-server:default:RepresentationConverter" }, + "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "source": { "@type": "ExpiringAdapterFactory", "storage": { diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json index 1dabe42e7..00fc69688 100644 --- a/config/identity/handler/default.json +++ b/config/identity/handler/default.json @@ -1,46 +1,46 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", "import": [ - "css:config/identity/handler/account-store/default.json", "css:config/identity/handler/adapter-factory/webid.json", - "css:config/identity/handler/interaction/routes.json", "css:config/identity/handler/jwks/default.json", - "css:config/identity/handler/provider-factory/identity.json" + "css:config/identity/handler/provider-factory/identity.json", + "css:config/identity/handler/storage/default.json", + "css:config/identity/handler/storage/password.json" ], "@graph": [ { "comment": "Routes all IDP related requests to the relevant handlers.", "@id": "urn:solid-server:default:IdentityProviderHandler", "@type": "RouterHandler", - "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "args_targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, - "args_allowedPathNames": [ "^/idp/.*" ], - "args_handler": { "@id": "urn:solid-server:default:IdentityProviderParsingHandler" } + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" }, + "allowedPathNames": [ "^/.account/.*" ], + "handler": { "@id": "urn:solid-server:default:IdentityProviderParsingHandler" } }, { "comment": "Handles IDP input parsing.", "@id": "urn:solid-server:default:IdentityProviderParsingHandler", "@type": "ParsingHttpHandler", - "args_requestParser": { "@id": "urn:solid-server:default:RequestParser" }, - "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }, - "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, - "args_operationHandler": { + "requestParser": { "@id": "urn:solid-server:default:RequestParser" }, + "errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }, + "responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, + "operationHandler": { "comment": "Handles IDP input authorization. Permission reader should be set to allow all if no authorization is needed.", "@type": "AuthorizingHttpHandler", "@id": "urn:solid-server:default:IdentityProviderAuthorizingHandler", - "args_credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" }, - "args_modesExtractor": { "@id": "urn:solid-server:default:ModesExtractor" }, - "args_authorizer": { "@id": "urn:solid-server:default:Authorizer" }, - "args_operationHandler": { "@id": "urn:solid-server:default:IdentityProviderHttpHandler" } + "credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" }, + "modesExtractor": { "@id": "urn:solid-server:default:ModesExtractor" }, + "authorizer": { "@id": "urn:solid-server:default:Authorizer" }, + "operationHandler": { "@id": "urn:solid-server:default:IdentityProviderHttpHandler" } } }, { "comment": "Handles IDP handler behaviour.", "@id": "urn:solid-server:default:IdentityProviderHttpHandler", "@type": "IdentityProviderHttpHandler", - "args_providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }, - "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, - "args_handler": { "@id": "urn:solid-server:default:InteractionHandler" } + "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }, + "cookieStore": { "@id": "urn:solid-server:default:CookieStore" }, + "handler": { "@id": "urn:solid-server:default:InteractionHandler" } } ] } diff --git a/config/identity/handler/interaction/routes.json b/config/identity/handler/interaction/routes.json deleted file mode 100644 index 133f7640a..000000000 --- a/config/identity/handler/interaction/routes.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "import": [ - "css:config/identity/handler/interaction/routes/consent.json", - "css:config/identity/handler/interaction/routes/credentials.json", - "css:config/identity/handler/interaction/routes/forgot-password.json", - "css:config/identity/handler/interaction/routes/index.json", - "css:config/identity/handler/interaction/routes/login.json", - "css:config/identity/handler/interaction/routes/prompt.json", - "css:config/identity/handler/interaction/routes/reset-password.json", - "css:config/identity/handler/interaction/views/controls.json", - "css:config/identity/handler/interaction/views/html.json" - ], - "@graph": [ - { - "@id": "urn:solid-server:default:InteractionHandler", - "@type": "WaterfallHandler", - "handlers": [ - { - "comment": "Returns the relevant HTML pages for the interactions when needed", - "@id": "urn:solid-server:auth:password:HtmlViewHandler" - }, - { - "comment": "Adds controls and API version to JSON responses.", - "@id": "urn:solid-server:auth:password:ControlHandler", - "@type": "ControlHandler", - "source" : { "@id": "urn:solid-server:auth:password:LocationInteractionHandler" } - } - ] - }, - { - "comment": "Converts 3xx redirects to 200 JSON responses for consumption by browser scripts.", - "@id": "urn:solid-server:auth:password:LocationInteractionHandler", - "@type": "LocationInteractionHandler", - "source" : { "@id": "urn:solid-server:auth:password:InteractionRouteHandler" } - }, - { - "comment": "Handles every interaction based on their route.", - "@id": "urn:solid-server:auth:password:InteractionRouteHandler", - "@type": "WaterfallHandler", - "handlers": [ - { "@id": "urn:solid-server:auth:password:IndexRouteHandler" }, - { "@id": "urn:solid-server:auth:password:PromptRouteHandler" }, - { "@id": "urn:solid-server:auth:password:LoginRouteHandler" }, - { "@id": "urn:solid-server:auth:password:ConsentRouteHandler" }, - { "@id": "urn:solid-server:auth:password:ForgotPasswordRouteHandler" }, - { "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler" }, - { "@id": "urn:solid-server:auth:password:CredentialsRouteHandler" } - ] - } - ] -} diff --git a/config/identity/handler/interaction/routes/consent.json b/config/identity/handler/interaction/routes/consent.json deleted file mode 100644 index e6c7ba2cd..000000000 --- a/config/identity/handler/interaction/routes/consent.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.", - "@id": "urn:solid-server:auth:password:ConsentRouteHandler", - "@type":"InteractionRouteHandler", - "route": { - "@id": "urn:solid-server:auth:password:ConsentRoute", - "@type": "RelativePathInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "relativePath": "/consent/" - }, - "source": { - "@id": "urn:solid-server:auth:password:ConsentHandler", - "@type": "ConsentHandler", - "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } - } - } - ] -} diff --git a/config/identity/handler/interaction/routes/credentials.json b/config/identity/handler/interaction/routes/credentials.json deleted file mode 100644 index 373e4c5ac..000000000 --- a/config/identity/handler/interaction/routes/credentials.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "Stores all client credential tokens.", - "@id": "urn:solid-server:auth:password:CredentialsStorage", - "@type": "Base64EncodingStorage", - "source": { - "@type": "ContainerPathStorage", - "relativePath": "/accounts/credentials/", - "source": { "@id": "urn:solid-server:default:KeyValueStorage" } - } - }, - { - "comment": "Handles credential tokens. These can be used to automate clients. See documentation for more info.", - "@id": "urn:solid-server:auth:password:CredentialsRouteHandler", - "@type":"InteractionRouteHandler", - "route": { - "@id": "urn:solid-server:auth:password:CredentialsRoute", - "@type": "RelativePathInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "relativePath": "/credentials/" - }, - "source": { - "@id": "urn:solid-server:auth:password:CredentialsHandler", - "@type": "EmailPasswordAuthorizer", - "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, - "source": { - "@type": "WaterfallHandler", - "handlers": [ - { - "@type": "CreateCredentialsHandler", - "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, - "credentialStorage": { "@id": "urn:solid-server:auth:password:CredentialsStorage" } - }, - { - "@type": "DeleteCredentialsHandler", - "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, - "credentialStorage": { "@id": "urn:solid-server:auth:password:CredentialsStorage" } - }, - { - "@type": "ListCredentialsHandler", - "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } - } - ] - } - } - }, - { - - } - ] -} diff --git a/config/identity/handler/interaction/routes/forgot-password.json b/config/identity/handler/interaction/routes/forgot-password.json deleted file mode 100644 index 22dec3011..000000000 --- a/config/identity/handler/interaction/routes/forgot-password.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "Handles the forgot password interaction", - "@id": "urn:solid-server:auth:password:ForgotPasswordRouteHandler", - "@type":"InteractionRouteHandler", - "route": { - "@id": "urn:solid-server:auth:password:ForgotPasswordRoute", - "@type": "RelativePathInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "relativePath": "/forgotpassword/" - }, - "source": { - "@id": "urn:solid-server:auth:password:ForgotPasswordHandler", - "@type": "ForgotPasswordHandler", - "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, - "args_templateEngine": { - "@type": "StaticTemplateEngine", - "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, - "template": "@css:templates/identity/email-password/reset-password-email.html.ejs" - }, - "args_emailSender": { "@id": "urn:solid-server:default:EmailSender" }, - "args_resetRoute": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } - } - } - ] -} diff --git a/config/identity/handler/interaction/routes/index.json b/config/identity/handler/interaction/routes/index.json deleted file mode 100644 index 0afc9a2a8..000000000 --- a/config/identity/handler/interaction/routes/index.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "Root API entry. Returns an empty body so we can add controls pointing to other interaction routes.", - "@id": "urn:solid-server:auth:password:IndexRouteHandler", - "@type": "InteractionRouteHandler", - "route": { - "@id": "urn:solid-server:auth:password:IndexRoute", - "@type": "RelativePathInteractionRoute", - "base": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "relativePath": "/idp/" - }, - "source": { - "@id": "urn:solid-server:auth:password:IndexHandler", - "@type": "FixedInteractionHandler", - "response": {} - } - } - ] -} diff --git a/config/identity/handler/interaction/routes/login.json b/config/identity/handler/interaction/routes/login.json deleted file mode 100644 index edec4e6ca..000000000 --- a/config/identity/handler/interaction/routes/login.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "Handles the login interaction", - "@id": "urn:solid-server:auth:password:LoginRouteHandler", - "@type": "InteractionRouteHandler", - "route": { - "@id": "urn:solid-server:auth:password:LoginRoute", - "@type": "RelativePathInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "relativePath": "/login/" - }, - "source": { - "@id": "urn:solid-server:auth:password:LoginHandler", - "@type": "LoginHandler", - "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } - } - } - ] -} diff --git a/config/identity/handler/interaction/routes/prompt.json b/config/identity/handler/interaction/routes/prompt.json deleted file mode 100644 index 14a7bb7e5..000000000 --- a/config/identity/handler/interaction/routes/prompt.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "Handles OIDC redirects containing a prompt, such as login or consent.", - "@id": "urn:solid-server:auth:password:PromptRouteHandler", - "@type": "InteractionRouteHandler", - "route": { - "@id": "urn:solid-server:auth:password:PromptRoute", - "@type": "RelativePathInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "relativePath": "/prompt/" - }, - "source": { - "@type": "PromptHandler", - "@id": "urn:solid-server:auth:password:PromptHandler", - "promptRoutes": [ - { - "PromptHandler:_promptRoutes_key": "login", - "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:LoginRoute" } - }, - { - "PromptHandler:_promptRoutes_key": "consent", - "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:ConsentRoute" } - } - ] - } - } - ] -} diff --git a/config/identity/handler/interaction/routes/reset-password.json b/config/identity/handler/interaction/routes/reset-password.json deleted file mode 100644 index 50bc4a085..000000000 --- a/config/identity/handler/interaction/routes/reset-password.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "Handles the reset password interaction", - "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler", - "@type": "InteractionRouteHandler", - "route": { - "@id": "urn:solid-server:auth:password:ResetPasswordRoute", - "@type": "RelativePathInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "relativePath": "/resetpassword/" - }, - "source": { - "@id": "urn:solid-server:auth:password:ResetPasswordHandler", - "@type": "ResetPasswordHandler", - "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" } - } - } - ] -} diff --git a/config/identity/handler/interaction/views/controls.json b/config/identity/handler/interaction/views/controls.json deleted file mode 100644 index 6d24e952c..000000000 --- a/config/identity/handler/interaction/views/controls.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "@graph": [ - { - "@id": "urn:solid-server:auth:password:ControlHandler", - "@type": "ControlHandler", - "controls": [ - { - "ControlHandler:_controls_key": "index", - "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:IndexRoute" } - }, - { - "ControlHandler:_controls_key": "prompt", - "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:PromptRoute" } - }, - { - "ControlHandler:_controls_key": "login", - "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:LoginRoute" } - }, - { - "ControlHandler:_controls_key": "forgotPassword", - "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" } - }, - { - "ControlHandler:_controls_key": "credentials", - "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:CredentialsRoute" } - } - ] - } - ] -} diff --git a/config/identity/handler/interaction/views/html.json b/config/identity/handler/interaction/views/html.json deleted file mode 100644 index e7615c63a..000000000 --- a/config/identity/handler/interaction/views/html.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "@graph": [ - { - "@id": "urn:solid-server:auth:password:HtmlViewHandler", - "@type": "HtmlViewHandler", - "index": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "templateEngine": { - "comment": "Renders the specific page and embeds it into the main HTML body.", - "@type": "ChainedTemplateEngine", - "renderedName": "htmlBody", - "engines": [ - { - "comment": "Will be called with specific templates to generate HTML snippets.", - "@id": "urn:solid-server:default:TemplateEngine" - }, - { - "comment": "Will embed the result of the first engine into the main HTML template.", - "@type": "StaticTemplateEngine", - "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, - "template": "@css:templates/main.html.ejs" - } - ] - }, - "templates": [ - { - "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/login.html.ejs", - "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:LoginRoute" } - }, - { - "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/consent.html.ejs", - "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ConsentRoute" } - }, - { - "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/forgot-password.html.ejs", - "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" } - }, - { - "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/reset-password.html.ejs", - "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" } - } - ] - } - ] -} diff --git a/config/identity/handler/provider-factory/identity.json b/config/identity/handler/provider-factory/identity.json index 47f92a7cb..b44a46fae 100644 --- a/config/identity/handler/provider-factory/identity.json +++ b/config/identity/handler/provider-factory/identity.json @@ -5,16 +5,28 @@ "comment": "Sets all the relevant Solid-OIDC parameters.", "@id": "urn:solid-server:default:IdentityProviderFactory", "@type": "IdentityProviderFactory", + "promptFactory": { + "@id": "urn:solid-server:default:PromptFactory", + "@type": "SequenceHandler", + "handlers": [ + { + "@type": "AccountPromptFactory", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "cookieStore": { "@id": "urn:solid-server:default:CookieStore" }, + "cookieName": { "@id": "urn:solid-server:default:value:accountCookieName" } + } + ] + }, "adapterFactory": { "@id": "urn:solid-server:default:IdpAdapterFactory" }, "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, "oidcPath": "/.oidc", - "interactionHandler": { "@id": "urn:solid-server:auth:password:PromptHandler" }, - "credentialStorage": { "@id": "urn:solid-server:auth:password:CredentialsStorage" }, + "clientCredentialsStore": { "@id": "urn:solid-server:default:ClientCredentialsStore" }, "storage": { "@id": "urn:solid-server:default:KeyStorage" }, "jwkGenerator": { "@id": "urn:solid-server:default:JwkGenerator" }, "showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" }, "errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }, "responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }, + "interactionRoute": { "@id": "urn:solid-server:default:IndexRoute" }, "config": { "claims": { "openid": [ "azp" ], diff --git a/config/identity/handler/storage/default.json b/config/identity/handler/storage/default.json new file mode 100644 index 000000000..26d4b2e5f --- /dev/null +++ b/config/identity/handler/storage/default.json @@ -0,0 +1,78 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "@id": "urn:solid-server:default:AccountStore", + "@type": "BaseAccountStore", + "storage": { + "@id": "urn:solid-server:default:AccountStorage", + "@type": "WrappedExpiringStorage", + "source": { + "@type": "Base64EncodingStorage", + "source": { + "@type": "ContainerPathStorage", + "relativePath": "/accounts/data/", + "source": { "@id": "urn:solid-server:default:KeyValueStorage" } + } + } + } + }, + + { + "@id": "urn:solid-server:default:WebIdStore", + "@type": "BaseWebIdStore", + "webIdRoute": { "@id": "urn:solid-server:default:AccountWebIdLinkRoute" }, + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "storage": { + "@id": "urn:solid-server:default:WebIdStorage", + "@type": "Base64EncodingStorage", + "source": { + "@type": "ContainerPathStorage", + "relativePath": "/accounts/webIds/", + "source": { "@id": "urn:solid-server:default:KeyValueStorage" } + } + } + }, + + { + "@id": "urn:solid-server:default:CookieStore", + "@type": "BaseCookieStore", + "storage": { + "@id": "urn:solid-server:default:CookieStorage", + "@type": "WrappedExpiringStorage", + "source": { + "@type": "Base64EncodingStorage", + "source": { + "@type": "ContainerPathStorage", + "relativePath": "/accounts/cookies/", + "source": { "@id": "urn:solid-server:default:KeyValueStorage" } + } + } + } + }, + + { + "@id": "urn:solid-server:default:PodStore", + "@type": "BasePodStore", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "podRoute": { "@id": "urn:solid-server:default:AccountPodIdRoute" }, + "manager": { "@id": "urn:solid-server:default:PodManager" } + }, + + { + "@id": "urn:solid-server:default:ClientCredentialsStore", + "@type": "BaseClientCredentialsStore", + "clientCredentialsRoute": { "@id": "urn:solid-server:default:AccountClientCredentialsIdRoute" }, + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "storage": { + "@id": "urn:solid-server:default:ClientCredentialsStorage", + "@type": "Base64EncodingStorage", + "source": { + "@type": "ContainerPathStorage", + "relativePath": "/accounts/client-credentials/", + "source": { "@id": "urn:solid-server:default:KeyValueStorage" } + } + } + } + ] +} diff --git a/config/identity/handler/storage/password.json b/config/identity/handler/storage/password.json new file mode 100644 index 000000000..461a3dc79 --- /dev/null +++ b/config/identity/handler/storage/password.json @@ -0,0 +1,35 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "@id": "urn:solid-server:default:PasswordStore", + "@type": "BasePasswordStore", + "storage": { + "@id": "urn:solid-server:default:PasswordStorage", + "@type": "Base64EncodingStorage", + "source": { + "@type": "ContainerPathStorage", + "relativePath": "/accounts/logins/password/", + "source": { "@id": "urn:solid-server:default:KeyValueStorage" } + } + } + }, + + { + "@id": "urn:solid-server:default:ForgotPasswordStore", + "@type": "BaseForgotPasswordStore", + "storage": { + "@id": "urn:solid-server:default:ForgotPasswordStorage", + "@type": "WrappedExpiringStorage", + "source": { + "@type": "Base64EncodingStorage", + "source": { + "@type": "ContainerPathStorage", + "relativePath": "/accounts/logins/password/forgot/", + "source": { "@id": "urn:solid-server:default:KeyValueStorage" } + } + } + } + } + ] +} diff --git a/config/identity/interaction/default.json b/config/identity/interaction/default.json new file mode 100644 index 000000000..42c50b204 --- /dev/null +++ b/config/identity/interaction/default.json @@ -0,0 +1,16 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "import": [ + "css:config/identity/interaction/routing/default.json", + "css:config/identity/interaction/enable/account.json", + "css:config/identity/interaction/enable/client-credentials.json", + "css:config/identity/interaction/enable/password.json", + "css:config/identity/interaction/enable/pod.json", + "css:config/identity/interaction/enable/webid.json" + ], + "@graph": [ + { + "comment": "Enables all account-related features." + } + ] +} diff --git a/config/identity/interaction/enable/account.json b/config/identity/interaction/enable/account.json new file mode 100644 index 000000000..4bed2cdd6 --- /dev/null +++ b/config/identity/interaction/enable/account.json @@ -0,0 +1,56 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Enable account creation." + }, + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:AccountRouter" }] + }, + + { + "@id": "urn:solid-server:default:AccountControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "create", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountRoute" } + }] + }, + + { + "comment": "The parts below are specific for password logins, but will not cause issues should password logins be disabled." + }, + { + "comment": "Route only used for an HTML page (and its corresponding controls).", + "@id": "urn:solid-server:default:RegisterPasswordRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:LoginPasswordRoute" }, + "relativePath": "register/" + }, + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [ + { + "@id": "urn:solid-server:default:RegisterPasswordAccountHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/password/register.html.ejs", + "route": { "@id": "urn:solid-server:default:RegisterPasswordRoute" } + } + ] + }, + { + "@id": "urn:solid-server:default:PasswordHtmlControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "register", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:RegisterPasswordRoute" } + } + ] + } + ] +} + diff --git a/config/identity/interaction/enable/client-credentials.json b/config/identity/interaction/enable/client-credentials.json new file mode 100644 index 000000000..fde0e5feb --- /dev/null +++ b/config/identity/interaction/enable/client-credentials.json @@ -0,0 +1,43 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Enable client credentials creation." + }, + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:AccountClientCredentialsRouter" }] + }, + + { + "@id": "urn:solid-server:default:AccountControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "clientCredentials", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountClientCredentialsRoute" } + }] + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:CreateClientCredentialsHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/account/create-client-credentials.html.ejs", + "route": { "@id": "urn:solid-server:default:AccountClientCredentialsRoute" } + }] + }, + { + "ControlHandler:_controls_value": { + "@id": "urn:solid-server:default:AccountHtmlControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "createClientCredentials", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountClientCredentialsRoute" } + }] + } + } + ] +} diff --git a/config/identity/interaction/enable/password.json b/config/identity/interaction/enable/password.json new file mode 100644 index 000000000..568804b80 --- /dev/null +++ b/config/identity/interaction/enable/password.json @@ -0,0 +1,52 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Enable creating email/password combinations as a login mechanism." + }, + { + "@id": "urn:solid-server:default:ControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "password", + "ControlHandler:_controls_value": { + "comment": "All controls associated with the password login method.", + "@id": "urn:solid-server:default:PasswordControlHandler", + "@type": "ControlHandler", + "controls": [] + } + } + ] + }, + + { + "comment": "Adds a link to the login page of this auth method to the list that contains all options", + "@id": "urn:solid-server:default:LoginHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "Email/password combination", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:LoginPasswordRoute" } + } + ] + }, + + { + "@id": "urn:solid-server:default:HtmlControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "password", + "ControlHandler:_controls_value": { + "comment": "Contains the controls linking to all HTML pages related to password authentication.", + "@id": "urn:solid-server:default:PasswordHtmlControlHandler", + "@type": "ControlHandler", + "controls": [] + } + } + ] + } + ] +} + diff --git a/config/identity/interaction/enable/pod.json b/config/identity/interaction/enable/pod.json new file mode 100644 index 000000000..a8fc343a9 --- /dev/null +++ b/config/identity/interaction/enable/pod.json @@ -0,0 +1,44 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Enable pod creation." + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [ + { "@id": "urn:solid-server:default:AccountPodRouter" } + ] + }, + + { + "@id": "urn:solid-server:default:AccountControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "pod", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountPodRoute" } + }] + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:CreatePodHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/account/create-pod.html.ejs", + "route": { "@id": "urn:solid-server:default:AccountPodRoute" } + }] + }, + { + "@id": "urn:solid-server:default:AccountHtmlControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "createPod", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountPodRoute" } + }] + } + ] +} diff --git a/config/identity/interaction/enable/webid.json b/config/identity/interaction/enable/webid.json new file mode 100644 index 000000000..a8dffa40f --- /dev/null +++ b/config/identity/interaction/enable/webid.json @@ -0,0 +1,44 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Enable linking WebIDs to an account." + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [ + { "@id": "urn:solid-server:default:AccountWebIdRouter" } + ] + }, + + { + "@id": "urn:solid-server:default:AccountControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "webId", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountWebIdRoute" } + }] + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:LinkWebIdHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/account/link-webid.html.ejs", + "route": { "@id": "urn:solid-server:default:AccountWebIdRoute" } + }] + }, + { + "@id": "urn:solid-server:default:AccountHtmlControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "linkWebId", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountWebIdRoute" } + }] + } + ] +} diff --git a/config/identity/interaction/no-accounts.json b/config/identity/interaction/no-accounts.json new file mode 100644 index 000000000..4d25c2c35 --- /dev/null +++ b/config/identity/interaction/no-accounts.json @@ -0,0 +1,15 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "import": [ + "css:config/identity/interaction/routing/default.json", + "css:config/identity/interaction/enable/client-credentials.json", + "css:config/identity/interaction/enable/password.json", + "css:config/identity/interaction/enable/pod.json", + "css:config/identity/interaction/enable/webid.json" + ], + "@graph": [ + { + "comment": "Disables account creation." + } + ] +} diff --git a/config/identity/interaction/no-pods.json b/config/identity/interaction/no-pods.json new file mode 100644 index 000000000..92091ad3f --- /dev/null +++ b/config/identity/interaction/no-pods.json @@ -0,0 +1,15 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "import": [ + "css:config/identity/interaction/routing/default.json", + "css:config/identity/interaction/enable/account.json", + "css:config/identity/interaction/enable/client-credentials.json", + "css:config/identity/interaction/enable/password.json", + "css:config/identity/interaction/enable/webid.json" + ], + "@graph": [ + { + "comment": "Disabled pod creation." + } + ] +} diff --git a/config/identity/interaction/routing/account/create.json b/config/identity/interaction/routing/account/create.json new file mode 100644 index 000000000..5b6515dac --- /dev/null +++ b/config/identity/interaction/routing/account/create.json @@ -0,0 +1,37 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the account creation", + "@id": "urn:solid-server:default:AccountRouter", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:default:AccountRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:IndexRoute" }, + "relativePath": "account/" + }, + "source": { + "@type": "ViewInteractionHandler", + "source": { + "@id": "urn:solid-server:default:CreateAccountHandler", + "@type": "CreateAccountHandler", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "cookieStore": { "@id": "urn:solid-server:default:CookieStore" }, + "accountRoute": { "@id": "urn:solid-server:default:AccountIdRoute" } + } + } + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:AccountHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/account/account.html.ejs", + "route": { "@id": "urn:solid-server:default:AccountRoute" } + }] + } + ] +} diff --git a/config/identity/interaction/routing/account/login.json b/config/identity/interaction/routing/account/login.json new file mode 100644 index 000000000..bd5ac57b6 --- /dev/null +++ b/config/identity/interaction/routing/account/login.json @@ -0,0 +1,14 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Base account login route that specific login implementations can extend.", + "@id": "urn:solid-server:default:AccountLoginRoute", + "@type": "RelativePathInteractionRoute", + "base": { + "@id": "urn:solid-server:default:AccountIdRoute" + }, + "relativePath": "login/" + } + ] +} diff --git a/config/identity/interaction/routing/account/logout.json b/config/identity/interaction/routing/account/logout.json new file mode 100644 index 000000000..52db095e5 --- /dev/null +++ b/config/identity/interaction/routing/account/logout.json @@ -0,0 +1,40 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles logging a user out.", + "@id": "urn:solid-server:default:AccountLogoutRouter", + "@type": "AuthorizedRouteHandler", + "route": { + "@id": "urn:solid-server:default:AccountLogoutRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:AccountIdRoute" }, + "relativePath": "logout/" + }, + "source": { + "@type": "MethodFilterHandler", + "methods": [ "POST" ], + "source": { + "@id": "urn:solid-server:default:LogoutHandler", + "@type": "LogoutHandler", + "cookieStore": { "@id": "urn:solid-server:default:CookieStore" } + } + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:AccountLogoutRouter" }] + }, + + { + "@id": "urn:solid-server:default:AccountControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "logout", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountLogoutRoute" } + }] + } + ] +} diff --git a/config/identity/interaction/routing/account/main.json b/config/identity/interaction/routing/account/main.json new file mode 100644 index 000000000..9591bc533 --- /dev/null +++ b/config/identity/interaction/routing/account/main.json @@ -0,0 +1,42 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "import": [ + "css:config/identity/interaction/routing/account/create.json", + "css:config/identity/interaction/routing/account/login.json", + "css:config/identity/interaction/routing/account/logout.json", + "css:config/identity/interaction/routing/account/resource.json" + ], + "@graph": [ + { + "@id": "urn:solid-server:default:ControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "account", + "ControlHandler:_controls_value": { + "comment": "All controls related to account management.", + "@id": "urn:solid-server:default:AccountControlHandler", + "@type": "ControlHandler", + "controls": [] + } + } + ] + }, + + { + "@id": "urn:solid-server:default:HtmlControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "account", + "ControlHandler:_controls_value": { + "comment": "Controls linking to account-related HTML pages.", + "@id": "urn:solid-server:default:AccountHtmlControlHandler", + "@type": "ControlHandler", + "controls": [] + } + } + ] + } + ] +} diff --git a/config/identity/interaction/routing/account/resource.json b/config/identity/interaction/routing/account/resource.json new file mode 100644 index 000000000..3121b5c0d --- /dev/null +++ b/config/identity/interaction/routing/account/resource.json @@ -0,0 +1,60 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the account details.", + "@id": "urn:solid-server:default:AccountIdRouter", + "@type": "AuthorizedRouteHandler", + "route": { + "@id": "urn:solid-server:default:AccountIdRoute", + "@type": "BaseAccountIdRoute", + "base": { "@id": "urn:solid-server:default:AccountRoute" } + }, + "source": { + "@id": "urn:solid-server:default:AccountResourceHandler", + "@type": "MethodFilterHandler", + "methods": [ "GET" ], + "source": { + "@type": "AccountDetailsHandler", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" } + } + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:AccountIdRouter" }] + }, + + { + "@id": "urn:solid-server:default:AccountControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "account", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountIdRoute" } + }] + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:AccountIdHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/account/resource.html.ejs", + "route": { "@id": "urn:solid-server:default:AccountIdRoute" } + }] + }, + { + "@id": "urn:solid-server:default:AccountHtmlControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "account", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountIdRoute" } + } + ] + } + ] +} diff --git a/config/identity/interaction/routing/client-credentials/create.json b/config/identity/interaction/routing/client-credentials/create.json new file mode 100644 index 000000000..a41a40717 --- /dev/null +++ b/config/identity/interaction/routing/client-credentials/create.json @@ -0,0 +1,25 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles credential tokens. These can be used to automate clients. See documentation for more info.", + "@id": "urn:solid-server:default:AccountClientCredentialsRouter", + "@type": "AuthorizedRouteHandler", + "route": { + "@id": "urn:solid-server:default:AccountClientCredentialsRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:AccountIdRoute" }, + "relativePath": "client-credentials/" + }, + "source": { + "@type": "ViewInteractionHandler", + "source": { + "@id": "urn:solid-server:default:CreateClientCredentialsHandler", + "@type": "CreateClientCredentialsHandler", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "clientCredentialsStore": { "@id": "urn:solid-server:default:ClientCredentialsStore" } + } + } + } + ] +} diff --git a/config/identity/interaction/routing/client-credentials/resource.json b/config/identity/interaction/routing/client-credentials/resource.json new file mode 100644 index 000000000..14c48da16 --- /dev/null +++ b/config/identity/interaction/routing/client-credentials/resource.json @@ -0,0 +1,45 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the client credentials link details such as deletion.", + "@id": "urn:solid-server:default:AccountClientCredentialsIdRouter", + "@type": "AuthorizedRouteHandler", + "route": { + "@id": "urn:solid-server:default:AccountClientCredentialsIdRoute", + "@type": "BaseClientCredentialsIdRoute", + "base": { "@id": "urn:solid-server:default:AccountClientCredentialsRoute" } + }, + "source": { + "@id": "urn:solid-server:default:ClientCredentialsResourceHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "@type": "MethodFilterHandler", + "methods": [ "GET" ], + "source": { + "@type": "ClientCredentialsDetailsHandler", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "clientCredentialsStore": { "@id": "urn:solid-server:default:ClientCredentialsStore" } + } + }, + { + "@type": "MethodFilterHandler", + "methods": [ "DELETE" ], + "source": { + "@type": "DeleteClientCredentialsHandler", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "clientCredentialsStore": { "@id": "urn:solid-server:default:ClientCredentialsStore" } + } + } + ] + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:AccountClientCredentialsIdRouter" }] + } + ] +} diff --git a/config/identity/interaction/routing/core/index.json b/config/identity/interaction/routing/core/index.json new file mode 100644 index 000000000..730eb0613 --- /dev/null +++ b/config/identity/interaction/routing/core/index.json @@ -0,0 +1,49 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Root API entry. Returns an empty body so we can add controls pointing to other interaction routes.", + "@id": "urn:solid-server:default:IndexRouter", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:default:IndexRoute", + "@type": "RelativePathInteractionRoute", + "base": { + "@type": "AbsolutePathInteractionRoute", + "path": { "@id": "urn:solid-server:default:variable:baseUrl" } + }, + "relativePath": ".account/" + }, + "source": { + "@type": "StaticInteractionHandler", + "response": {} + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:IndexRouter" }] + }, + + { + "@id": "urn:solid-server:default:MainControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "index", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:IndexRoute" } + }] + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:IndexHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/index.html.ejs", + "route": { "@id": "urn:solid-server:default:IndexRoute" } + }] + } + ] +} diff --git a/config/identity/interaction/routing/core/login.json b/config/identity/interaction/routing/core/login.json new file mode 100644 index 000000000..82e3f5b64 --- /dev/null +++ b/config/identity/interaction/routing/core/login.json @@ -0,0 +1,72 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Returns the links of the HTML pages that can be used to log in with specific methods.", + "@id": "urn:solid-server:default:LoginRouter", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:default:LoginRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:IndexRoute" }, + "relativePath": "login/" + }, + "source": { + "@type": "MethodFilterHandler", + "methods": [ "GET" ], + "source": { + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "logins", + "ControlHandler:_controls_value": { + "comment": "New login methods should add a link to their HTML login page here. This list can be used when multiple login methods exist to make a choice.", + "@id": "urn:solid-server:default:LoginHandler", + "@type": "ControlHandler", + "controls": [] + } + } + ] + } + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:LoginRouter" }] + }, + + { + "@id": "urn:solid-server:default:MainControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "logins", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:LoginRoute" } + } + ] + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:LoginHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/login.html.ejs", + "route": { "@id": "urn:solid-server:default:LoginRoute" } + }] + }, + { + "@id": "urn:solid-server:default:MainHtmlControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "login", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:LoginRoute" } + } + ] + } + ] +} diff --git a/config/identity/interaction/routing/core/main.json b/config/identity/interaction/routing/core/main.json new file mode 100644 index 000000000..73978b3cb --- /dev/null +++ b/config/identity/interaction/routing/core/main.json @@ -0,0 +1,38 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "import": [ + "css:config/identity/interaction/routing/core/index.json", + "css:config/identity/interaction/routing/core/login.json" + ], + "@graph": [ + { + "@id": "urn:solid-server:default:ControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "main", + "ControlHandler:_controls_value": { + "comment": "Contains all general controls.", + "@id": "urn:solid-server:default:MainControlHandler", + "@type": "ControlHandler", + "controls": [] + } + } + ] + }, + + { + "@id": "urn:solid-server:default:HtmlControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "main", + "ControlHandler:_controls_value": { + "comment": "Controls all general HTML page controls.", + "@id": "urn:solid-server:default:MainHtmlControlHandler", + "@type": "ControlHandler", + "controls": [] + } + }] + } + ] +} diff --git a/config/identity/interaction/routing/default.json b/config/identity/interaction/routing/default.json new file mode 100644 index 000000000..ed5d6c9d1 --- /dev/null +++ b/config/identity/interaction/routing/default.json @@ -0,0 +1,85 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "import": [ + "css:config/identity/interaction/routing/account/main.json", + "css:config/identity/interaction/routing/client-credentials/create.json", + "css:config/identity/interaction/routing/client-credentials/resource.json", + "css:config/identity/interaction/routing/core/main.json", + "css:config/identity/interaction/routing/oidc/main.json", + "css:config/identity/interaction/routing/password/main.json", + "css:config/identity/interaction/routing/pod/create.json", + "css:config/identity/interaction/routing/pod/resource.json", + "css:config/identity/interaction/routing/webid/link.json", + "css:config/identity/interaction/routing/webid/resource.json", + + "css:config/identity/interaction/routing/views/html.json" + ], + "@graph": [ + { + "@id": "urn:solid-server:default:InteractionHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "comment": "Returns the relevant HTML pages for the interactions when needed.", + "@id": "urn:solid-server:default:HtmlViewHandler" + }, + { + "comment": "Ensures locks on authenticated requests.", + "@id": "urn:solid-server:default:LockingInteractionHandler", + "@type": "LockingInteractionHandler", + "locker": { "@id": "urn:solid-server:default:ResourceLocker" }, + "accountRoute": { "@id": "urn:solid-server:default:AccountIdRoute" }, + "source": { "@id": "urn:solid-server:default:JsonConversionHandler" } + } + ] + }, + { + "comment": "Convert incoming requests to JSON operations.", + "@id": "urn:solid-server:default:JsonConversionHandler", + "@type": "JsonConversionHandler", + "source": { "@id": "urn:solid-server:default:VersionHandler" }, + "converter": { "@id": "urn:solid-server:default:RepresentationConverter" } + }, + { + "comment": "Adds the API version to the JSON response.", + "@id": "urn:solid-server:default:VersionHandler", + "@type": "VersionHandler", + "source" : { "@id": "urn:solid-server:default:CookieInteractionHandler" } + }, + { + "comment": "Updates the cookie values as necessary.", + "@id": "urn:solid-server:default:CookieInteractionHandler", + "@type": "CookieInteractionHandler", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "cookieStore": { "@id": "urn:solid-server:default:CookieStore" }, + "source": { "@id": "urn:solid-server:default:RootControlHandler" } + }, + { + "comment": "Adds controls to the JSON response.", + "@id": "urn:solid-server:default:RootControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "controls", + "ControlHandler:_controls_value": { + "comment": "The main controls object. All other controls should be added to this one.", + "@id": "urn:solid-server:default:ControlHandler", + "@type": "ControlHandler", + "controls": [] + } + }], + "source" : { "@id": "urn:solid-server:default:LocationInteractionHandler" } + }, + { + "comment": "Converts 3xx redirects to 200 JSON responses for consumption by browser scripts.", + "@id": "urn:solid-server:default:LocationInteractionHandler", + "@type": "LocationInteractionHandler", + "source" : { "@id": "urn:solid-server:default:InteractionRouteHandler" } + }, + { + "comment": "Contains all JsonInteractionHandlers that can potentially handle the input request.", + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [] + } + ] +} diff --git a/config/identity/interaction/routing/oidc/cancel.json b/config/identity/interaction/routing/oidc/cancel.json new file mode 100644 index 000000000..d60da3222 --- /dev/null +++ b/config/identity/interaction/routing/oidc/cancel.json @@ -0,0 +1,44 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Allows users to cancel an OIDC interaction, bringing them back to the original client.", + "@id": "urn:solid-server:default:OidcCancelRouter", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:default:OidcCancelRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:OidcRoute" }, + "relativePath": "cancel/" + }, + "source": { + "@id": "urn:solid-server:default:CancelOidcHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "@type": "MethodFilterHandler", + "methods": [ "POST" ], + "source": { "@type": "CancelOidcHandler" } + } + ] + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:OidcCancelRouter" }] + }, + + { + "@id": "urn:solid-server:default:OidcControlHandler", + "@type": "OidcControlHandler", + "controls": [ + { + "OidcControlHandler:_controls_key": "cancel", + "OidcControlHandler:_controls_value": { "@id": "urn:solid-server:default:OidcCancelRoute" } + } + ] + } + ] +} diff --git a/config/identity/interaction/routing/oidc/consent.json b/config/identity/interaction/routing/oidc/consent.json new file mode 100644 index 000000000..941a5842b --- /dev/null +++ b/config/identity/interaction/routing/oidc/consent.json @@ -0,0 +1,66 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.", + "@id": "urn:solid-server:default:OidcConsentRouter", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:default:OidcConsentRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:OidcRoute" }, + "relativePath": "consent/" + }, + "source": { + "@id": "urn:solid-server:default:ConsentHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "@type": "MethodFilterHandler", + "methods": [ "GET" ], + "source": { + "@type": "ClientInfoHandler", + "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } + } + }, + { + "@type": "MethodFilterHandler", + "methods": [ "POST" ], + "source": { + "@type": "ConsentHandler", + "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } + } + } + ] + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:OidcConsentRouter" }] + }, + + { + "@id": "urn:solid-server:default:OidcControlHandler", + "@type": "OidcControlHandler", + "controls": [ + { + "OidcControlHandler:_controls_key": "consent", + "OidcControlHandler:_controls_value": { "@id": "urn:solid-server:default:OidcConsentRoute" } + } + ] + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:OidcConsentHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/oidc/consent.html.ejs", + "route": { "@id": "urn:solid-server:default:OidcConsentRoute" } + }] + } + ] +} diff --git a/config/identity/interaction/routing/oidc/forget-webid.json b/config/identity/interaction/routing/oidc/forget-webid.json new file mode 100644 index 000000000..be8e18f05 --- /dev/null +++ b/config/identity/interaction/routing/oidc/forget-webid.json @@ -0,0 +1,47 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Allows the picked WebID to be forgotten in an OIDC interaction so the user can pick again.", + "@id": "urn:solid-server:default:OidcForgetWebIdRouter", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:default:OidcForgetWebIDRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:OidcRoute" }, + "relativePath": "forget-webid/" + }, + "source": { + "@id": "urn:solid-server:default:ForgetWebIdHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "@type": "MethodFilterHandler", + "methods": [ "POST" ], + "source": { + "@type": "ForgetWebIdHandler", + "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } + } + } + ] + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:OidcForgetWebIdRouter" }] + }, + + { + "@id": "urn:solid-server:default:OidcControlHandler", + "@type": "OidcControlHandler", + "controls": [ + { + "OidcControlHandler:_controls_key": "forgetWebId", + "OidcControlHandler:_controls_value": { "@id": "urn:solid-server:default:OidcForgetWebIDRoute" } + } + ] + } + ] +} diff --git a/config/identity/interaction/routing/oidc/main.json b/config/identity/interaction/routing/oidc/main.json new file mode 100644 index 000000000..72df29378 --- /dev/null +++ b/config/identity/interaction/routing/oidc/main.json @@ -0,0 +1,35 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "import": [ + "css:config/identity/interaction/routing/oidc/cancel.json", + "css:config/identity/interaction/routing/oidc/consent.json", + "css:config/identity/interaction/routing/oidc/forget-webid.json", + "css:config/identity/interaction/routing/oidc/prompt.json", + "css:config/identity/interaction/routing/oidc/pick-webid.json" + ], + "@graph": [ + { + "comment": "Main OIDC route others can extend.", + "@id": "urn:solid-server:default:OidcRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:IndexRoute" }, + "relativePath": "oidc/" + }, + + { + "@id": "urn:solid-server:default:ControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "oidc", + "ControlHandler:_controls_value": { + "comment": "Contains all OIDC controls.", + "@id": "urn:solid-server:default:OidcControlHandler", + "@type": "OidcControlHandler", + "controls": [] + } + } + ] + } + ] +} diff --git a/config/identity/interaction/routing/oidc/pick-webid.json b/config/identity/interaction/routing/oidc/pick-webid.json new file mode 100644 index 000000000..4c7e48e30 --- /dev/null +++ b/config/identity/interaction/routing/oidc/pick-webid.json @@ -0,0 +1,44 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles picking a WebID during an OIDC interaction.", + "@id": "urn:solid-server:default:OidcPickWebIdRouter", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:default:OidcPickWebIdRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:OidcRoute" }, + "relativePath": "pick-webid/" + }, + "source": { + "@type": "ViewInteractionHandler", + "source": { + "@type": "PickWebIdHandler", + "@id": "urn:solid-server:default:PickWebIdHandler", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" } + } + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [ + { "@id": "urn:solid-server:default:OidcPickWebIdRouter" } + ] + }, + + { + "@id": "urn:solid-server:default:OidcControlHandler", + "@type": "OidcControlHandler", + "controls": [ + { + "OidcControlHandler:_controls_key": "webId", + "OidcControlHandler:_controls_value": { "@id": "urn:solid-server:default:OidcPickWebIdRoute" } + } + ] + } + ] +} diff --git a/config/identity/interaction/routing/oidc/prompt.json b/config/identity/interaction/routing/oidc/prompt.json new file mode 100644 index 000000000..4b3ed401c --- /dev/null +++ b/config/identity/interaction/routing/oidc/prompt.json @@ -0,0 +1,51 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.", + "@id": "urn:solid-server:default:OidcPromptRouter", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:default:OidcPromptRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:OidcRoute" }, + "relativePath": "prompt/" + }, + "source": { + "@id": "urn:solid-server:default:PromptHandler", + "@type": "PromptHandler", + "promptRoutes": [ + { + "PromptHandler:_promptRoutes_key": "account", + "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:default:LoginRoute" } + }, + { + "PromptHandler:_promptRoutes_key": "login", + "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:default:OidcConsentRoute" } + }, + { + "PromptHandler:_promptRoutes_key": "consent", + "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:default:OidcConsentRoute" } + } + ] + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:OidcPromptRouter" }] + }, + + { + "@id": "urn:solid-server:default:OidcControlHandler", + "@type": "OidcControlHandler", + "controls": [ + { + "OidcControlHandler:_controls_key": "prompt", + "OidcControlHandler:_controls_value": { "@id": "urn:solid-server:default:OidcPromptRoute" } + } + ] + } + ] +} diff --git a/config/identity/interaction/routing/password/create.json b/config/identity/interaction/routing/password/create.json new file mode 100644 index 000000000..ed699c45b --- /dev/null +++ b/config/identity/interaction/routing/password/create.json @@ -0,0 +1,62 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Allows adding the email/password login method to an account", + "@id": "urn:solid-server:default:AccountPasswordRouter", + "@type": "AuthorizedRouteHandler", + "route": { + "@id": "urn:solid-server:default:AccountPasswordRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:AccountLoginRoute" }, + "relativePath": "password/" + }, + "source": { + "@type": "ViewInteractionHandler", + "source": { + "@id": "urn:solid-server:default:CreatePasswordHandler", + "@type": "CreatePasswordHandler", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" }, + "passwordRoute": { "@id": "urn:solid-server:default:AccountPasswordIdRoute" } + } + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:AccountPasswordRouter" }] + }, + + { + "@id": "urn:solid-server:default:PasswordControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "create", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountPasswordRoute" } + }] + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:CreatePasswordHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/password/create.html.ejs", + "route": { "@id": "urn:solid-server:default:AccountPasswordRoute" } + }] + }, + { + "ControlHandler:_controls_value": { + "@id": "urn:solid-server:default:PasswordHtmlControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "create", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountPasswordRoute" } + }] + } + } + ] +} diff --git a/config/identity/interaction/routing/password/forgot.json b/config/identity/interaction/routing/password/forgot.json new file mode 100644 index 000000000..d42665eaa --- /dev/null +++ b/config/identity/interaction/routing/password/forgot.json @@ -0,0 +1,68 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the forgot password interaction.", + "@id": "urn:solid-server:default:ForgotPasswordRouter", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:default:ForgotPasswordRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:LoginPasswordRoute" }, + "relativePath": "forgot/" + }, + "source": { + "@type": "ViewInteractionHandler", + "source": { + "@id": "urn:solid-server:default:ForgotPasswordHandler", + "@type": "ForgotPasswordHandler", + "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" }, + "forgotPasswordStore": { "@id": "urn:solid-server:default:ForgotPasswordStore" }, + "templateEngine": { + "@type": "StaticTemplateEngine", + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, + "template": "@css:templates/identity/password/reset-email.html.ejs" + }, + "emailSender": { "@id": "urn:solid-server:default:EmailSender" }, + "resetRoute": { "@id": "urn:solid-server:default:ResetPasswordRoute" } + } + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:ForgotPasswordRouter" }] + }, + + { + "@id": "urn:solid-server:default:PasswordControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "forgot", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:ForgotPasswordRoute" } + }] + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:ForgotPasswordHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/password/forgot.html.ejs", + "route": { "@id": "urn:solid-server:default:ForgotPasswordRoute" } + }] + }, + { + "ControlHandler:_controls_value": { + "@id": "urn:solid-server:default:PasswordHtmlControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "forgot", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:ForgotPasswordRoute" } + }] + } + } + ] +} diff --git a/config/identity/interaction/routing/password/login.json b/config/identity/interaction/routing/password/login.json new file mode 100644 index 000000000..bc17f6f6d --- /dev/null +++ b/config/identity/interaction/routing/password/login.json @@ -0,0 +1,63 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the password login interaction.", + "@id": "urn:solid-server:default:LoginPasswordRouter", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:default:LoginPasswordRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:LoginRoute" }, + "relativePath": "password/" + }, + "source": { + "@type": "ViewInteractionHandler", + "source": { + "@id": "urn:solid-server:default:PasswordLoginHandler", + "@type": "PasswordLoginHandler", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" }, + "cookieStore": { "@id": "urn:solid-server:default:CookieStore" }, + "accountRoute": { "@id": "urn:solid-server:default:AccountIdRoute" } + } + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:LoginPasswordRouter" }] + }, + + { + "@id": "urn:solid-server:default:PasswordControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "login", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:LoginPasswordRoute" } + }] + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:PasswordLoginHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/password/login.html.ejs", + "route": { "@id": "urn:solid-server:default:LoginPasswordRoute" } + }] + }, + { + "ControlHandler:_controls_value": { + "@id": "urn:solid-server:default:PasswordHtmlControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "login", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:LoginPasswordRoute" } + }] + } + } + ] +} diff --git a/config/identity/interaction/routing/password/main.json b/config/identity/interaction/routing/password/main.json new file mode 100644 index 000000000..0e9a509e3 --- /dev/null +++ b/config/identity/interaction/routing/password/main.json @@ -0,0 +1,11 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "import": [ + "css:config/identity/interaction/routing/password/create.json", + "css:config/identity/interaction/routing/password/forgot.json", + "css:config/identity/interaction/routing/password/login.json", + "css:config/identity/interaction/routing/password/reset.json", + "css:config/identity/interaction/routing/password/resource.json" + ], + "@graph": [] +} diff --git a/config/identity/interaction/routing/password/reset.json b/config/identity/interaction/routing/password/reset.json new file mode 100644 index 000000000..f83980015 --- /dev/null +++ b/config/identity/interaction/routing/password/reset.json @@ -0,0 +1,51 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the reset password interaction.", + "@id": "urn:solid-server:default:ResetPasswordRouter", + "@type": "InteractionRouteHandler", + "route": { + "@id": "urn:solid-server:default:ResetPasswordRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:LoginPasswordRoute" }, + "relativePath": "reset/" + }, + "source": { + "@type": "ViewInteractionHandler", + "source": { + "@id": "urn:solid-server:default:ResetPasswordHandler", + "@type": "ResetPasswordHandler", + "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" }, + "forgotPasswordStore": { "@id": "urn:solid-server:default:ForgotPasswordStore" } + } + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:ResetPasswordRouter" }] + }, + + { + "@id": "urn:solid-server:default:PasswordControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "reset", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:ResetPasswordRoute" } + }] + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:ResetPasswordHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/password/reset.html.ejs", + "route": { "@id": "urn:solid-server:default:ResetPasswordRoute" } + }] + } + ] +} diff --git a/config/identity/interaction/routing/password/resource.json b/config/identity/interaction/routing/password/resource.json new file mode 100644 index 000000000..44f41552d --- /dev/null +++ b/config/identity/interaction/routing/password/resource.json @@ -0,0 +1,55 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the password link details such as update and delete.", + "@id": "urn:solid-server:default:AccountPasswordIdRouter", + "@type": "AuthorizedRouteHandler", + "route": { + "@id": "urn:solid-server:default:AccountPasswordIdRoute", + "@type": "BasePasswordIdRoute", + "base": { "@id": "urn:solid-server:default:AccountPasswordRoute" } + }, + "source": { + "@id": "urn:solid-server:default:PasswordResourceHandler", + "@type": "WaterfallHandler", + "handlers": [ + { + "@type": "ViewInteractionHandler", + "source": { + "@type": "UpdatePasswordHandler", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" } + } + }, + { + "@type": "MethodFilterHandler", + "methods": [ "DELETE" ], + "source": { + "@type": "DeletePasswordHandler", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" } + } + } + ] + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [{ "@id": "urn:solid-server:default:AccountPasswordIdRouter" }] + }, + + { + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:UpdatePasswordHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/password/update.html.ejs", + "route": { "@id": "urn:solid-server:default:AccountPasswordIdRoute" } + }] + } + ] +} diff --git a/config/identity/interaction/routing/pod/create.json b/config/identity/interaction/routing/pod/create.json new file mode 100644 index 000000000..dbf123189 --- /dev/null +++ b/config/identity/interaction/routing/pod/create.json @@ -0,0 +1,30 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles pod creation.", + "@id": "urn:solid-server:default:AccountPodRouter", + "@type": "AuthorizedRouteHandler", + "route": { + "@id": "urn:solid-server:default:AccountPodRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:AccountIdRoute" }, + "relativePath": "pod/" + }, + "source": { + "@type": "ViewInteractionHandler", + "source": { + "@id": "urn:solid-server:default:CreatePodHandler", + "@type": "CreatePodHandler", + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" }, + "relativeWebIdPath": "/profile/card#me", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, + "podStore": { "@id": "urn:solid-server:default:PodStore" }, + "allowRoot": false + } + } + } + ] +} diff --git a/config/identity/interaction/routing/pod/resource.json b/config/identity/interaction/routing/pod/resource.json new file mode 100644 index 000000000..14efe3e59 --- /dev/null +++ b/config/identity/interaction/routing/pod/resource.json @@ -0,0 +1,11 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "This route is only used when creating new pod resources as no further interactions are supported.", + "@id": "urn:solid-server:default:AccountPodIdRoute", + "@type": "BasePodIdRoute", + "base": { "@id": "urn:solid-server:default:AccountPodRoute" } + } + ] +} diff --git a/config/identity/interaction/routing/views/html.json b/config/identity/interaction/routing/views/html.json new file mode 100644 index 000000000..ea559f527 --- /dev/null +++ b/config/identity/interaction/routing/views/html.json @@ -0,0 +1,45 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Returns HTML pages if the URL matches and HTML is preferred.", + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "index": { "@id": "urn:solid-server:default:IndexRoute" }, + "templateEngine": { + "comment": "Renders the specific page and embeds it into the main HTML body.", + "@type": "ChainedTemplateEngine", + "renderedName": "htmlBody", + "engines": [ + { + "comment": "Will be called with specific templates to generate HTML snippets.", + "@id": "urn:solid-server:default:TemplateEngine" + }, + { + "comment": "Will embed the result of the first engine into the main HTML template.", + "@type": "StaticTemplateEngine", + "templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" }, + "template": "@css:templates/main.html.ejs" + } + ] + }, + "templates": [] + }, + + { + "@id": "urn:solid-server:default:ControlHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "html", + "ControlHandler:_controls_value": { + "comment": "Controls linking to HTML pages. These can be the same URLs as the JSON APIs, but can also be different.", + "@id": "urn:solid-server:default:HtmlControlHandler", + "@type": "ControlHandler", + "controls": [] + } + } + ] + } + ] +} diff --git a/config/identity/interaction/routing/webid/link.json b/config/identity/interaction/routing/webid/link.json new file mode 100644 index 000000000..58d57c3bc --- /dev/null +++ b/config/identity/interaction/routing/webid/link.json @@ -0,0 +1,29 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles linking a WebID to an account", + "@id": "urn:solid-server:default:AccountWebIdRouter", + "@type": "AuthorizedRouteHandler", + "route": { + "@id": "urn:solid-server:default:AccountWebIdRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:AccountIdRoute" }, + "relativePath": "webid/" + }, + "source": { + "@id": "urn:solid-server:default:WebIdHandler", + "@type": "ViewInteractionHandler", + "source": { + "@id": "urn:solid-server:default:LinkWebIdHandler", + "@type": "LinkWebIdHandler", + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "ownershipValidator": { "@id": "urn:solid-server:default:OwnershipValidator" }, + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, + "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" } + } + } + } + ] +} diff --git a/config/identity/interaction/routing/webid/resource.json b/config/identity/interaction/routing/webid/resource.json new file mode 100644 index 000000000..04a11fe73 --- /dev/null +++ b/config/identity/interaction/routing/webid/resource.json @@ -0,0 +1,33 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Handles the WebID link details such as deletion.", + "@id": "urn:solid-server:default:AccountWebIdLinkRouter", + "@type": "AuthorizedRouteHandler", + "route": { + "@id": "urn:solid-server:default:AccountWebIdLinkRoute", + "@type": "BaseWebIdLinkRoute", + "base": { "@id": "urn:solid-server:default:AccountWebIdRoute" } + }, + "source": { + "@id": "urn:solid-server:default:WebIdLinkHandler", + "@type": "MethodFilterHandler", + "methods": [ "DELETE" ], + "source": { + "@type": "UnlinkWebIdHandler", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" } + } + } + }, + + { + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [ + { "@id": "urn:solid-server:default:AccountWebIdLinkRouter" } + ] + } + ] +} diff --git a/config/identity/ownership/token.json b/config/identity/ownership/token.json index 2720d005c..b22eea23c 100644 --- a/config/identity/ownership/token.json +++ b/config/identity/ownership/token.json @@ -3,7 +3,7 @@ "@graph": [ { "comment": "Determines WebID ownership by requesting a specific value to be added to the WebID document", - "@id": "urn:solid-server:auth:password:OwnershipValidator", + "@id": "urn:solid-server:default:OwnershipValidator", "@type": "TokenOwnershipValidator", "storage": { "@id": "urn:solid-server:default:ExpiringTokenStorage" } }, diff --git a/config/identity/ownership/unsafe-no-check.json b/config/identity/ownership/unsafe-no-check.json index 09ac4f191..a9fcd1b2a 100644 --- a/config/identity/ownership/unsafe-no-check.json +++ b/config/identity/ownership/unsafe-no-check.json @@ -6,7 +6,7 @@ "DO NOT USE IN PRODUCTION. ONLY FOR DEVELOPMENT, TESTING, OR DEBUGGING.", "Do no verification to determine WebID ownership." ], - "@id": "urn:solid-server:auth:password:OwnershipValidator", + "@id": "urn:solid-server:default:OwnershipValidator", "@type": "NoCheckOwnershipValidator" } ] diff --git a/config/identity/registration/disabled.json b/config/identity/registration/disabled.json deleted file mode 100644 index ac7d0d1f6..000000000 --- a/config/identity/registration/disabled.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "Disable registration by not attaching a registration handler." - } - ] -} diff --git a/config/identity/registration/enabled.json b/config/identity/registration/enabled.json deleted file mode 100644 index 8569827b1..000000000 --- a/config/identity/registration/enabled.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "import": [ - "css:config/identity/registration/route/registration.json" - ], - "@graph": [ - { - "@id": "urn:solid-server:auth:password:InteractionRouteHandler", - "@type": "WaterfallHandler", - "handlers": [ - { "@id": "urn:solid-server:auth:password:RegistrationRouteHandler" } - ] - }, - { - "@id": "urn:solid-server:auth:password:ControlHandler", - "@type": "ControlHandler", - "controls": [ - { - "ControlHandler:_controls_key": "register", - "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" } - } - ] - }, - { - "@id": "urn:solid-server:auth:password:HtmlViewHandler", - "@type": "HtmlViewHandler", - "templates": [ - { - "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/register.html.ejs", - "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" } - } - ] - } - ] -} diff --git a/config/identity/registration/route/registration.json b/config/identity/registration/route/registration.json deleted file mode 100644 index 40cc9400d..000000000 --- a/config/identity/registration/route/registration.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", - "@graph": [ - { - "comment": "Handles the register interaction", - "@id": "urn:solid-server:auth:password:RegistrationRouteHandler", - "@type": "InteractionRouteHandler", - "route": { - "@id": "urn:solid-server:auth:password:RegistrationRoute", - "@type": "RelativePathInteractionRoute", - "base": { "@id": "urn:solid-server:auth:password:IndexRoute" }, - "relativePath": "/register/" - }, - "source": { - "@id": "urn:solid-server:auth:password:RegistrationHandler", - "@type": "RegistrationHandler", - "registrationManager": { - "@type": "RegistrationManager", - "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, - "args_webIdSuffix": "/profile/card#me", - "args_identifierGenerator": { "@id": "urn:solid-server:default:IdentifierGenerator" }, - "args_ownershipValidator": { "@id": "urn:solid-server:auth:password:OwnershipValidator" }, - "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, - "args_podManager": { "@id": "urn:solid-server:default:PodManager" } - } - } - } - ] -} diff --git a/config/ldp/authorization/readers/ownership.json b/config/ldp/authorization/readers/ownership.json index cc744b0d7..445ec93be 100644 --- a/config/ldp/authorization/readers/ownership.json +++ b/config/ldp/authorization/readers/ownership.json @@ -5,7 +5,8 @@ "comment": "Allows pod owners to always edit permissions on the data.", "@id": "urn:solid-server:default:OwnerPermissionReader", "@type": "OwnerPermissionReader", - "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }, + "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" } } ] diff --git a/config/ldp/metadata-parser/default.json b/config/ldp/metadata-parser/default.json index c08773857..f26ed70a1 100644 --- a/config/ldp/metadata-parser/default.json +++ b/config/ldp/metadata-parser/default.json @@ -1,7 +1,9 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", "import": [ + "css:config/ldp/metadata-parser/parsers/authorization.json", "css:config/ldp/metadata-parser/parsers/content-type.json", + "css:config/ldp/metadata-parser/parsers/cookie.json", "css:config/ldp/metadata-parser/parsers/link.json", "css:config/ldp/metadata-parser/parsers/plain-json-ld-filter.json", "css:config/ldp/metadata-parser/parsers/slug.json" @@ -12,6 +14,8 @@ "@id": "urn:solid-server:default:MetadataParser", "@type": "ParallelHandler", "handlers": [ + { "@id": "urn:solid-server:default:AuthorizationParser" }, + { "@id": "urn:solid-server:default:CookieParser" }, { "@id": "urn:solid-server:default:ContentTypeParser" }, { "@id": "urn:solid-server:default:LinkRelParser" }, { "@id": "urn:solid-server:default:PlainJsonLdFilter" }, diff --git a/config/ldp/metadata-parser/parsers/authorization.json b/config/ldp/metadata-parser/parsers/authorization.json new file mode 100644 index 000000000..c2a98c071 --- /dev/null +++ b/config/ldp/metadata-parser/parsers/authorization.json @@ -0,0 +1,16 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Converts the authorization header into RDF metadata", + "@id": "urn:solid-server:default:AuthorizationParser", + "@type": "AuthorizationParser", + "authMap": [ + { + "AuthorizationParser:_authMap_key": "CSS-Account-Cookie", + "AuthorizationParser:_authMap_value": "urn:npm:solid:community-server:http:accountCookie" + } + ] + } + ] +} diff --git a/config/ldp/metadata-parser/parsers/cookie.json b/config/ldp/metadata-parser/parsers/cookie.json new file mode 100644 index 000000000..2824257ef --- /dev/null +++ b/config/ldp/metadata-parser/parsers/cookie.json @@ -0,0 +1,21 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "The name of the cookie to identify being logged in with a CSS account.", + "@id": "urn:solid-server:default:value:accountCookieName", + "valueRaw": "css-account" + }, + { + "comment": "Converts cookies into RDF metadata.", + "@id": "urn:solid-server:default:CookieParser", + "@type": "CookieParser", + "cookieMap": [ + { + "CookieParser:_cookieMap_key": { "@id": "urn:solid-server:default:value:accountCookieName" }, + "CookieParser:_cookieMap_value": "urn:npm:solid:community-server:http:accountCookie", + } + ] + } + ] +} diff --git a/config/ldp/metadata-writer/default.json b/config/ldp/metadata-writer/default.json index f90bf6344..23ca135e6 100644 --- a/config/ldp/metadata-writer/default.json +++ b/config/ldp/metadata-writer/default.json @@ -3,6 +3,7 @@ "import": [ "css:config/ldp/metadata-writer/writers/allow-accept.json", "css:config/ldp/metadata-writer/writers/content-type.json", + "css:config/ldp/metadata-writer/writers/cookie.json", "css:config/ldp/metadata-writer/writers/link-rel.json", "css:config/ldp/metadata-writer/writers/link-rel-metadata.json", "css:config/ldp/metadata-writer/writers/mapped.json", @@ -21,6 +22,7 @@ { "@id": "urn:solid-server:default:MetadataWriter_ContentType" }, { "@id": "urn:solid-server:default:MetadataWriter_LinkRel" }, { "@id": "urn:solid-server:default:MetadataWriter_LinkRelMetadata" }, + { "@id": "urn:solid-server:default:MetadataWriter_Cookie" }, { "@id": "urn:solid-server:default:MetadataWriter_Mapped" }, { "@id": "urn:solid-server:default:MetadataWriter_Modified" }, { "@id": "urn:solid-server:default:MetadataWriter_Range" }, diff --git a/config/ldp/metadata-writer/writers/cookie.json b/config/ldp/metadata-writer/writers/cookie.json new file mode 100644 index 000000000..e6dc06124 --- /dev/null +++ b/config/ldp/metadata-writer/writers/cookie.json @@ -0,0 +1,17 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Converts all triples with the given predicates to cookies.", + "@id": "urn:solid-server:default:MetadataWriter_Cookie", + "@type": "CookieMetadataWriter", + "cookieMap": [ + { + "CookieMetadataWriter:_cookieMap_key": "urn:npm:solid:community-server:http:accountCookie", + "CookieMetadataWriter:_name": { "@id": "urn:solid-server:default:value:accountCookieName" }, + "CookieMetadataWriter:_expirationUri": "urn:npm:solid:community-server:http:accountCookieExpiration" + } + ] + } + ] +} diff --git a/config/memory-subdomains.json b/config/memory-subdomains.json index eac0a3fc6..ac65e72ba 100644 --- a/config/memory-subdomains.json +++ b/config/memory-subdomains.json @@ -2,7 +2,7 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", "import": [ "css:config/app/main/default.json", - "css:config/app/init/initialize-root.json", + "css:config/app/init/static-root.json", "css:config/app/variables/default.json", "css:config/http/handler/default.json", "css:config/http/middleware/default.json", @@ -12,9 +12,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/config/path-routing.json b/config/path-routing.json index 2993aad14..8e4722d96 100644 --- a/config/path-routing.json +++ b/config/path-routing.json @@ -12,9 +12,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/no-accounts.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/disabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/config/quota-file.json b/config/quota-file.json index a5efd9e2a..0fb9d4067 100644 --- a/config/quota-file.json +++ b/config/quota-file.json @@ -12,9 +12,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/config/restrict-idp.json b/config/restrict-idp.json index 4d62e350b..ddb5768cb 100644 --- a/config/restrict-idp.json +++ b/config/restrict-idp.json @@ -12,9 +12,9 @@ "css:config/identity/access/restricted.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/config/sparql-endpoint-root.json b/config/sparql-endpoint-root.json index 318ca9d2f..9e494a0cc 100644 --- a/config/sparql-endpoint-root.json +++ b/config/sparql-endpoint-root.json @@ -12,9 +12,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/no-accounts.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/disabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/config/sparql-endpoint.json b/config/sparql-endpoint.json index fe04a91ae..77162ebea 100644 --- a/config/sparql-endpoint.json +++ b/config/sparql-endpoint.json @@ -12,9 +12,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/config/sparql-file-storage.json b/config/sparql-file-storage.json index 96ac60771..353c349bf 100644 --- a/config/sparql-file-storage.json +++ b/config/sparql-file-storage.json @@ -12,9 +12,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/config/util/variables/default.json b/config/util/variables/default.json index 7ed7b5f0c..fc4e00666 100644 --- a/config/util/variables/default.json +++ b/config/util/variables/default.json @@ -44,7 +44,7 @@ }, { "comment": "Path to the JSON file used to seed pods.", - "@id": "urn:solid-server:default:variable:seededPodConfigJson", + "@id": "urn:solid-server:default:variable:seedConfig", "@type": "Variable" }, { diff --git a/documentation/markdown/README.md b/documentation/markdown/README.md index 1c8500621..7c35a6cc0 100644 --- a/documentation/markdown/README.md +++ b/documentation/markdown/README.md @@ -32,7 +32,7 @@ the [changelog](https://github.com/CommunitySolidServer/CommunitySolidServer/blo * [Quickly starting the server](usage/starting-server.md) * [Basic example HTTP requests](usage/example-requests.md) * [Editing the metadata of a resource](usage/metadata.md) -* [How to use the Identity Provider](usage/identity-provider.md) +* [How to use the Identity Provider and accounts](usage/identity-provider.md) * [How to automate authentication](usage/client-credentials.md) * [How to automatically seed pods on startup](usage/seeding-pods.md) * [Receiving notifications when resources change](usage/notifications.md) diff --git a/documentation/markdown/architecture/features/accounts/controls.md b/documentation/markdown/architecture/features/accounts/controls.md new file mode 100644 index 000000000..62eaafa6c --- /dev/null +++ b/documentation/markdown/architecture/features/accounts/controls.md @@ -0,0 +1,27 @@ +# JSON API controls + +A large part of every response of the JSON API is the `controls` block. +These are generated by using nested `ControlHandler` objects. +These take as input a key/value with the values being either routes or other interaction handlers. +These will then be executed to determine the values of the output JSON object, with the same keys. +By using other `ControlHandler`s in the input map, we can create nested objects. + +The default structure of these handlers is as follows: + +```mermaid +flowchart LR + RootControlHandler("RootControlHandler
ControlHandler") + RootControlHandler --controls--> ControlHandler("ControlHandler
ControlHandler") + ControlHandler --main--> MainControlHandler("MainControlHandler
ControlHandler") + ControlHandler --account--> AccountControlHandler("AccountControlHandler
ControlHandler") + ControlHandler --password--> PasswordControlHandler("PasswordControlHandler
ControlHandler") + ControlHandler --"oidc"--> OidcControlHandler("OidcControlHandler
OidcControlHandler") + ControlHandler --html--> HtmlControlHandler("HtmlControlHandler
ControlHandler") + + HtmlControlHandler --main--> MainHtmlControlHandler("MainHtmlControlHandler
ControlHandler") + HtmlControlHandler --account--> AccountHtmlControlHandler("AccountHtmlControlHandler
ControlHandler") + HtmlControlHandler --password--> PasswordHtmlControlHandler("PasswordHtmlControlHandler
ControlHandler") +``` + +Each of these control handlers then has a map of routes which link to the actual API endpoints. +How to add these can be seen [here](routes.md#adding-the-necessary-controls). diff --git a/documentation/markdown/architecture/features/accounts/overview.md b/documentation/markdown/architecture/features/accounts/overview.md new file mode 100644 index 000000000..f0e3f99b1 --- /dev/null +++ b/documentation/markdown/architecture/features/accounts/overview.md @@ -0,0 +1,58 @@ +# Account management + +The main entry point is the `IdentityProviderHandler`, +which routes all requests targeting a resource starting with `/.account/` into this handler, +after which it goes through similar parsing handlers as described [here](../protocol/overview.md), +the flow of which is shown below: + +```mermaid +flowchart LR + Handler("IdentityProviderHandler
RouterHandler") + ParsingHandler("IdentityProviderParsingHandler
AuthorizingHttpHandler") + AuthorizingHandler("IdentityProviderAuthorizingHandler
AuthorizingHttpHandler") + + Handler --> ParsingHandler + ParsingHandler --> AuthorizingHandler + AuthorizingHandler --> HttpHandler("IdentityProviderHttpHandler
IdentityProviderHttpHandler") +``` + +The `IdentityProviderHttpHandler` is where the actual differentiation of this component starts. +It handles identifying the account based on the supplied cookie and determining the active OIDC interaction, +after which it calls an `InteractionHandler` with this additional input. +The `InteractionHandler` is many handlers chained together as follows: + +```mermaid +flowchart TD + HttpHandler("IdentityProviderHttpHandler
IdentityProviderHttpHandler") + HttpHandler --> InteractionHandler("InteractionHandler
WaterfallHandler") + InteractionHandler --> InteractionHandlerArgs + + subgraph InteractionHandlerArgs[" "] + HtmlViewHandler("HtmlViewHandler
HtmlViewHandler") + LockingInteractionHandler("LockingInteractionHandler
LockingInteractionHandler") + end + + LockingInteractionHandler --> JsonConversionHandler("JsonConversionHandler
JsonConversionHandler") + JsonConversionHandler --> VersionHandler("VersionHandler
VersionHandler") + VersionHandler --> CookieInteractionHandler("CookieInteractionHandler
CookieInteractionHandler") + CookieInteractionHandler --> RootControlHandler("RootControlHandler
ControlHandler") + RootControlHandler --> LocationInteractionHandler("LocationInteractionHandler
LocationInteractionHandler") + LocationInteractionHandler --> InteractionRouteHandler("InteractionRouteHandler
WaterfallHandler") +``` + +The `HtmlViewHandler` catches all request that request an HTML output. +This class keeps a list of HTML pages and their corresponding URL and returns them when needed. + +If the request is for the JSON API, +the request goes through a chain of handlers, each responsible for a specific step in the API process. +We'll list and summarize these here: + +* `LockingInteractionHandler`: In case the request is authenticated, + this requests a lock on that account to prevent simultaneous operations on the same account. +* `JsonConversionHandler`: Converts the streaming input into a JSON object. +* `VersionHandler`: Adds a version number to all output. +* `CookieInteractionHandler`: Refreshes the cookie if necessary and adds relevant cookie metadata to the output. +* `RootControlHandler`: Responsible for adding all the [controls](controls.md) to the output. + Will take as input multiple other control handlers which create the nested values in the `controls` field. +* `LocationInteractionHandler`: Catches redirect errors and converts them to JSON objects with a `location` field. +* `InteractionRouteHandler`: A `WaterfallHandler` containing an entry for every supported API [route](routes.md). diff --git a/documentation/markdown/architecture/features/accounts/routes.md b/documentation/markdown/architecture/features/accounts/routes.md new file mode 100644 index 000000000..15e6f557a --- /dev/null +++ b/documentation/markdown/architecture/features/accounts/routes.md @@ -0,0 +1,126 @@ +# Account API routes + +All entries contained in the `urn:solid-server:default:InteractionRouteHandler` have a similar structure: +an `InteractionRouteHandler`, or `AuthorizedRouteHandler` for authenticated requests, +which checks if the request targets a specific URL +and redirects the request to its source if there is a match. +Its source is quite often a `ViewInteractionHandler`, +which returns a specific view on GET requests and performs an operation on POST requests, +but other handlers can also occur. + +Below we will give an example of one API route and all the components that are necessary to add it to the server. + +## Route handler + +```json +{ + "@id": "urn:solid-server:default:AccountWebIdRouter", + "@type": "AuthorizedRouteHandler", + "route": { + "@id": "urn:solid-server:default:AccountWebIdRoute", + "@type": "RelativePathInteractionRoute", + "base": { "@id": "urn:solid-server:default:AccountIdRoute" }, + "relativePath": "webid/" + }, + "source": { "@id": "urn:solid-server:default:WebIdHandler" } +} +``` + +The main entry point is the route handler, +which determines the URL necessary to reach this API. +In this case we create a new route, relative to the `urn:solid-server:default:AccountIdRoute`. +That route specifically matches URLs of the format `http://localhost:3000/.account/account//`. +Here we create a route relative to that one by appending `webid`, +so the resulting route would match `http://localhost:3000/.account/account//webid/`. +Since an `AuthorizedRouteHandler` is used here, +the request also needs to be authenticated using an account cookie. +If there is match, the request will be sent to the `urn:solid-server:default:WebIdHandler`. + +## Interaction handler + +```json +{ + "@id": "urn:solid-server:default:WebIdHandler", + "@type": "ViewInteractionHandler", + "source": { + "@id": "urn:solid-server:default:LinkWebIdHandler", + "@type": "LinkWebIdHandler", + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }, + "ownershipValidator": { "@id": "urn:solid-server:default:OwnershipValidator" }, + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "webIdStore": { "@id": "urn:solid-server:default:WebIdStore" }, + "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" } + } +} +``` + +The interaction handler is the class that performs the necessary operation based on the request. +Often these are wrapped in a `ViewInteractionHandler`, +which allows classes to have different support for GET and POST requests. + +## Exposing the API + +```json +{ + "@id": "urn:solid-server:default:InteractionRouteHandler", + "@type": "WaterfallHandler", + "handlers": [ + { "@id": "urn:solid-server:default:AccountWebIdRouter" } + ] +} +``` + +To make sure the API can be accessed, +it needs to be added to the list of `urn:solid-server:default:InteractionRouteHandler`. +This is the main handler that contains entries for all the APIs. +This block of Components.js adds the route handler defined above to that list. + +## Adding the necessary controls + +```json +{ + "@id": "urn:solid-server:default:AccountControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "webId", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountWebIdRoute" } + }] +} +``` + +To make sure people can find the API, +it is necessary to link it through the associated `controls` object. +This API is related to account management, +so we add its route in the account controls with the key `webId`. +More information about controls can be found [here](controls.md). + +## Adding HTML + +```json +{ + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:LinkWebIdHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/account/link-webid.html.ejs", + "route": { "@id": "urn:solid-server:default:AccountWebIdRoute" } + }] +} +``` + +Some API routes also have an associated HTML page, +in which case the page needs to be added to the `urn:solid-server:default:HtmlViewHandler`, +which is what we do here. +Usually you will also want to add HTML controls so the page can be found. + +```json +{ + "@id": "urn:solid-server:default:AccountHtmlControlHandler", + "@type": "ControlHandler", + "controls": [{ + "ControlHandler:_controls_key": "linkWebId", + "ControlHandler:_controls_value": { "@id": "urn:solid-server:default:AccountWebIdRoute" } + }] +} +``` diff --git a/documentation/markdown/architecture/features/http-handler.md b/documentation/markdown/architecture/features/http-handler.md index f9ad06582..7c2c5d5ed 100644 --- a/documentation/markdown/architecture/features/http-handler.md +++ b/documentation/markdown/architecture/features/http-handler.md @@ -88,8 +88,9 @@ More on this can be found in the [identity provider](../../../usage/identity-pro The `urn:solid-server:default:IdentityProviderHttpHandler` handles everything related to our custom identity provider API, such as registering, logging in, returning the relevant HTML pages, etc. -All these requests are identified by being on the `/idp/` subpath. +All these requests are identified by being on the `/.account/` subpath. More information on the API can be found in the [identity provider](../../../usage/identity-provider) documentation +The architectural overview can be found [here](accounts/overview.md). ## LdpHandler diff --git a/documentation/markdown/architecture/features/protocol/overview.md b/documentation/markdown/architecture/features/protocol/overview.md index 69a013f80..d1b0d5443 100644 --- a/documentation/markdown/architecture/features/protocol/overview.md +++ b/documentation/markdown/architecture/features/protocol/overview.md @@ -10,7 +10,7 @@ Below is a simplified view of how these handlers are linked. ```mermaid flowchart LR - LdpHandler("LdpHandler
ParsingHttphandler") + LdpHandler("LdpHandler
ParsingHttpHandler") LdpHandler --> AuthorizingHttpHandler("
AuthorizingHttpHandler") AuthorizingHttpHandler --> OperationHandler("OperationHandler
OperationHandler") OperationHandler --> ResourceStore("ResourceStore
ResourceStore") diff --git a/documentation/markdown/usage/account/json-api.md b/documentation/markdown/usage/account/json-api.md new file mode 100644 index 000000000..94532d303 --- /dev/null +++ b/documentation/markdown/usage/account/json-api.md @@ -0,0 +1,281 @@ +# Account management JSON API + +Everything related to account management is done through a JSON API, +of which we will describe all paths below. +There are also HTML pages available to handle account management +that use these APIs internally. +Links to these can be found in the HTML controls +All APIs expect JSON as input, and will return JSON objects as output. + +## Finding API URLs + +All URLs below are relative to the index account API URL, which by default is `http://localhost:3000/.account/`. +Every response of an API request will contain a `controls` object, +containing all the URLs of the other API endpoints. +It is generally advised to make use of these controls instead of hardcoding the URLs. +Only the initial index URL needs to be known then to find the controls. +Certain controls will be missing if those features are disabled in the configuration. + +## API requests + +Many APIs require a POST request to perform an action. +When doing a GET request on these APIs they will return an object describing what input is expected for the POST. + +## Authorization + +After logging in, the API will return a `set-cookie` header. +This cookie is necessary to have access to many of the APIs. +When including this cookie, the controls object will also be extended with new URLs that are now accessible. +When logging in, the response body JSON body will also contain a `cookie` field containing the cookie value. +Instead of using cookies, +this value can also be used in an `Authorization` header with auth scheme `CSS-Account-Cookie` +to achieve the same result. + +The expiration time of this cookie will be refreshed +every time there is a successful request to the server with that cookie. + +## Redirecting + +As redirects through status codes 3xx can make working with JSON APIs more difficult, +the API will never make use of this. +Instead, if a redirect is required after an action, +the response JSON object will return a `location` field. +This is the next URL that should be fetched. +This is mostly relevant in OIDC interactions as these cause the interaction to progress. + +## Controls + +Below is an overview of all the keys in a controls object returned by the server, +with all features enabled. +An example of what such an object looks like can be found at the [bottom](#example) of the page. + +### controls.main + +General controls that require no authentication. + +#### controls.main.index + +General entrypoint to the API. +Returns an empty object, including the controls, on all GET requests. + +#### controls.main.logins + +Returns an overview of all login systems available on the server in `logins` object. +Keys are a string description of the login system and values are links to their login pages. +This can be used to let users choose how they want to log in. +By default, the object only contains the email/password login system. + +### controls.account + +All controls related to account management. +All of these require authorization, except for the create action. + +#### controls.account.create + +Creates a new account on empty POST requests. +The response contains the necessary cookie values to log and a `resource` field containing the URL of the account. +This account can not be used until a login method has been added to it. +All other interactions will fail until this is the case. +See the [controls.password.create](#controlspasswordcreate) section below for more information on how to do this. +This account will expire after some time if no login method is added. + +#### controls.account.logout + +Logs the account out on an empty POST request. +Invalidates the cookie that was used. + +#### controls.account.webId + +POST requests link a WebID to the account, +allowing the account to identify as that WebID during an OIDC authentication interaction. +Expected input is an object containing a `webId` field. + +If the chosen WebID is contained within a Solid pod associated with this account, +the request will succeed immediately. +If not, an error will be thrown, +asking the user to add a specific triple to the WebID to confirm that they are the owner. +After this triple is added, a second request will be successful. + +#### controls.account.pod + +Creates a Solid pod for the account on POST requests. +The only required field is `name`, which will determine the name of the pod. + +Additionally, a `settings` object can be sent along, +the values of which will be sent to the templates used when generating the pod. +If this `settings` object contains a `webId` field, +that WebID will be the WebID that has initial access to the pod. + +If no WebID value is provided, +a WebID will be generated in the pod and immediately linked to the account +as described in [controls.account.webID](#controlsaccountwebid). +This WebID will then be the WebID that has initial access. + +#### controls.account.clientCredentials + +Creates a client credentials token on POST requests. +More information on these tokens can be found [here](../client-credentials.md). +Expected input is an object containing a `name` and `webId` field. +The name is optional and will be used to name the token, +the WebID determines which WebID you will identify as when using that token. +It needs to be a WebID linked to the account as described in [controls.account.webID](#controlsaccountwebid). + +#### controls.account.account + +This value corresponds to the resource URL of the account you received when creating it. +This returns all resources linked to this account, such as login methods, WebIDs, pods, and client credentials tokens. + +Below is an example response object: + +```json +{ + "logins": { + "password": { + "test@example.com": "http://localhost:3000/.account/account/c63c9e6f-48f8-40d0-8fec-238da893a7f2/login/password/test%40example.com/" + } + }, + "pods": { + "http://localhost:3000/test/": "http://localhost:3000/.account/account/c63c9e6f-48f8-40d0-8fec-238da893a7f2/pod/7def7830df1161e422537db594ad2b7412ffb735e0e2320cf3e90db19cd969f9/" + }, + "webIds": { + "http://localhost:3000/test/profile/card#me": "http://localhost:3000/.account/account/c63c9e6f-48f8-40d0-8fec-238da893a7f2/webid/5c1b70d3ffaa840394dda86889ed1569cf897ef3d6041fb4c9513f82144cbb7f/" + }, + "clientCredentials": { + "token_562cdeb5-d4b2-4905-9e62-8969ac10daaa": "http://localhost:3000/.account/account/c63c9e6f-48f8-40d0-8fec-238da893a7f2/client-credentials/token_562cdeb5-d4b2-4905-9e62-8969ac10daaa/" + }, + "settings": {} +} +``` + +In each of the sub-objects, the key is always the unique identifier of whatever is being described, +while the value is the resource URL that can potentially be used to modify the resource. +Removing an entry can be done by sending a DELETE request to the resource URL, +except for pods, which cannot be deleted. +Login methods can only be deleted if the account has at least 1 login method remaining afterwards. + +The password login resource URL can also be used to modify the password, +which can be done by sending a POST request to it with the body containing an `oldPassword` and a `newPassword` field. + +### controls.password + +Controls related to managing the email/password login method. + +#### controls.password.create + +POST requests create an email/password login and adds it to the account you are logged in as. +Expects `email` and `password` fields. + +#### controls.password.login + +POST requests log a user in and return the relevant cookie values. +Expected fields are `email`, `password`, and optionally a `remember` boolean. +The `remember` value determines if the returned cookie is only valid for the session, +or for a longer time. + +#### controls.password.forgot + +Can be used when a user forgets their password. +POST requests with an `email` field will send an email with a link to reset the password. + +#### controls.password.reset + +Used to handle reset password URLs generated when a user forgets their password. +Expected input values for the POST request are `recordId`, +which was generated when sending the reset mail, +and `password` with the new password value. + +### controls.oidc + +These controls are related to completing OIDC interactions. + +#### controls.oidc.cancel + +Sending a POST request to this API will cancel the OIDC interaction +and return the user to the client that started the interaction. + +#### controls.oidc.prompt + +This API is used to determine what the next necessary step is in the OIDC interaction. +The response will contain a `location` field, +containing the URL to the next page the user should go to, +and a `prompt` field, +indicating the next step that is necessary to progress the OIDC interaction. +The three possible prompts are the following: + +* **account**: The user needs to log in, so they have an account cookie. +* **login**: The user needs to pick the WebID they want to use in the resulting OIDC token. +* **consent**: The user needs to consent to the interaction. + +#### controls.oidc.webId + +Relevant for solving the **login** prompt. +GET request will return a list of WebIDs the user can choose from. +This is the same result as requesting the account information and looking at the linked WebIDs. +The POST requests expects a `webId` value and optionally a `remember` boolean. +The latter determines if the server should remember the picked WebID for later interactions. + +#### controls.oidc.forgetWebId + +POST requests to this API will cause the OIDC interaction to forget the picked WebID +so a new one can be picked by the user. + +#### controls.oidc.consent + +A GET request to this API will return all the relevant information about the client doing the request. +A POST requests causes the OIDC interaction to finish. +It can have an optional `remember` value, which allows for refresh tokens if it is set to true. + +#### controls.html + +All these controls link to HTML pages and are thus mostly relevant to provide links to let the user navigate around. + +## Example + +Below is an example of a controls object in a response. + +```json +{ + "main": { + "index": "http://localhost:3000/.account/", + "logins": "http://localhost:3000/.account/login/" + }, + "account": { + "create": "http://localhost:3000/.account/account/", + "logout": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/logout/", + "webId": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/webid/", + "pod": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/pod/", + "clientCredentials": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/client-credentials/", + "account": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/" + }, + "password": { + "create": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/login/password/", + "login": "http://localhost:3000/.account/login/password/", + "forgot": "http://localhost:3000/.account/login/password/forgot/", + "reset": "http://localhost:3000/.account/login/password/reset/" + }, + "oidc": { + "cancel": "http://localhost:3000/.account/oidc/cancel/", + "prompt": "http://localhost:3000/.account/oidc/prompt/", + "webId": "http://localhost:3000/.account/oidc/pick-webid/", + "forgetWebId": "http://localhost:3000/.account/oidc/forget-webid/", + "consent": "http://localhost:3000/.account/oidc/consent/" + }, + "html": { + "main": { + "login": "http://localhost:3000/.account/login/" + }, + "account": { + "createClientCredentials": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/client-credentials/", + "createPod": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/pod/", + "linkWebId": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/webid/", + "account": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/" + }, + "password": { + "register": "http://localhost:3000/.account/login/password/register/", + "login": "http://localhost:3000/.account/login/password/", + "create": "http://localhost:3000/.account/account/ade5c046-e882-4b56-80f4-18cb16433360/login/password/", + "forgot": "http://localhost:3000/.account/login/password/forgot/" + } + } +} +``` diff --git a/documentation/markdown/usage/account/login-method.md b/documentation/markdown/usage/account/login-method.md new file mode 100644 index 000000000..f5fe4f285 --- /dev/null +++ b/documentation/markdown/usage/account/login-method.md @@ -0,0 +1,118 @@ +# Adding a new login method + +By default, the server allows users to use email/password combinations to identify as the owner of their account. +But, just like with many other parts of the server, +this can be extended so other login methods can be used. +Here we'll cover everything that is necessary. + +## Components + +These are the components that are needed for adding a new login method. +Not all of these are mandatory, +but they can make the life of the user easier when trying to find and use the new method. +Also have a look at the general [structure](../../architecture/features/accounts/routes.md) +of new API components to see what is expected of such a component. + +### Create component + +There needs to be one or more components that allow a user +to create an instance of the new login method and assign it to their account. +The `CreatePasswordHandler` can be used as an example. +This does not necessarily have to happen in a single request, +potentially multiple requests can be used if the user has to perform actions on an external site for example. +The only thing that matters is that at the end there is a new entry in the account's `logins` object. + +When adding logins of your method a new key will need to be chosen to group these logins together. +The email/password method uses `password` for example. + +A new storage will probably need to be created to storage relevant metadata about this login method entry. +Below is an example of how the `PasswordStore` is created: + +```json +{ + "@id": "urn:solid-server:default:PasswordStore", + "@type": "BasePasswordStore", + "storage": { + "@id": "urn:solid-server:default:PasswordStorage", + "@type": "EncodingPathStorage", + "relativePath": "/accounts/logins/password/", + "source": { + "@id": "urn:solid-server:default:KeyValueStorage" + } + } +} +``` + +### Login component + +After creating a login instance, a user needs to be able to log in using the new method. +This can again be done with multiple API calls if necessary, +but the final one needs to be one that handles the necessary actions +such as creating a cookie and finishing the OIDC interaction if necessary. +The `ResolveLoginHandler` can be extended to take care of most of this, +the `PasswordLoginHandler` provides an example of this. + +### Additional components + +Besides creating a login instance and logging in, +it is always possible to offer additional functionality specific to this login method. +The email/password method, for example, also has components for password recovery and updating a password. + +### HTML pages + +To make the life easier for users, +at the very least you probably want to make an HTML page which people can use +to create an instance of your login method. +Besides that you could also make a page where people can combine creating an account with creating a login instance. +The `templates/identity` folder contains all the pages the server has by default, +which can be used as inspiration. + +These pages need to be linked to the `urn:solid-server:default:HtmlViewHandler`. +Below is an example of this: + +```json +{ + "@id": "urn:solid-server:default:HtmlViewHandler", + "@type": "HtmlViewHandler", + "templates": [{ + "@id": "urn:solid-server:default:CreatePasswordHtml", + "@type": "HtmlViewEntry", + "filePath": "@css:templates/identity/password/create.html.ejs", + "route": { + "@id": "urn:solid-server:default:AccountPasswordRoute" + } + }] +} +``` + +### Updating the login handler + +The `urn:solid-server:default:LoginHandler` returns a list of available login methods, +which are used to offer users a choice of which login method they want to use on the default login page. +If you want the new method to also be offered you will have to add similar Components.js configuration: + +```json +{ + "@id": "urn:solid-server:default:LoginHandler", + "@type": "ControlHandler", + "controls": [ + { + "ControlHandler:_controls_key": "Email/password combination", + "ControlHandler:_controls_value": { + "@id": "urn:solid-server:default:LoginPasswordRoute" + } + } + ] +} +``` + +### Controls + +All new relevant API endpoints should be added to the controls object, +otherwise there is no way for users to find out where to send their requests. +Similarly, links to the HTML pages should also be in the controls, so they can be navigated to. +Examples of how to do this can be found [here](../../architecture/features/accounts/routes.md). + +The default account overview page makes some assumptions about the controls when building the page. +Specifically, it checks if `controls.html..create` exists, +if yes, it automatically creates a link on the page so users can create new login instances for their account. diff --git a/documentation/markdown/usage/account/migration.md b/documentation/markdown/usage/account/migration.md new file mode 100644 index 000000000..123994170 --- /dev/null +++ b/documentation/markdown/usage/account/migration.md @@ -0,0 +1,60 @@ +# Migrating account data from v6 to v7 + +Below is a description of the changes that are necessary to migration account data from v6 to v7 of the server. +Note that the resource identifier values are bas64 encoded before being appended to the storage location. + +* "Forgot password" records + * **Storage location** + * Old: `.internal/forgot-password/` + * New: `.internal/accounts/login/password/forgot/` + * **Resource identifiers** + * Old: `"forgot-password-resource-identifier/" + recordId` + * New: `recordId` + * **Data format** + * Old: `{ recordId, email }` + * New: `email` + * **Notes** + * Just deleting all existing records is an acceptable solution as these do not contain important information. +* Client credentials tokens + * **Storage location** + * Old: `.internal/accounts/credentials/` + * New: `.internal/accounts/client-credentials/` + * **Resource identifiers** + * No change + * **Data format** + * Old: `{ webId, secret }` + * New: `{ accountId, webId, secret }` + * **Notes** + * Account IDs will need to be generated first before these can be transferred. +* Account and password data + * **Storage location** + * Old: `.internal/accounts/` + * New: Split up over the following: + * `.internal/accounts/data/` + * `.internal/accounts/webIds/` + * `.internal/accounts/logins/password/` + * **Resource identifiers** + * Old: `"account/" + encodeURIComponent(email)` or `webId` + * New: + * `.internal/accounts/data/`: Newly generated account ID. + * `.internal/accounts/webIds/`: `webID` + * `.internal/accounts/logins/password/`: `encodeURIComponent(email.toLowerCase())` + * **Data format** + * Old: `{ webId, email, password, verified }` or `{ useIdp, podBaseUrl?, clientCredentials? }` + * New: + * `.internal/accounts/data/`: `{ id, logins: { password }, pods, webIds, clientCredentials }` + * `.internal/accounts/webIds/`: `accountId[]` + * `.internal/accounts/logins/password/`: `{ accountId, password, verified }` + * **Notes** + * First account IDs need to be generated, + then login/pod/webId/clientCredentials resources need to be generated, + and then the account needs to be updated with those resources. + * Resource URLs are generated as follows: + * Passwords: `/.account/account//login/password/` + * Pods: `/.account/account//pod/` + * WebIds: `/.account/account//webid/` + * Client Credentials: `/.account/account//client-credentials/` + * The above URLs are the values in all the account objects, + the keys are the corresponding (lowercase) email, pod base URL, webID, and token name. + * Only WebIDs where `useIdp` is `true` need to be linked to the account. + * In the previous version, a WebID will be linked to exactly 1 account. diff --git a/documentation/markdown/usage/identity-provider.md b/documentation/markdown/usage/identity-provider.md index efdc636b5..2acbe4b7b 100644 --- a/documentation/markdown/usage/identity-provider.md +++ b/documentation/markdown/usage/identity-provider.md @@ -8,48 +8,18 @@ It is recommended to use the latest version of the [Solid authentication client](https://github.com/inrupt/solid-client-authn-js) to interact with the server. -The links here assume the server is hosted at `http://localhost:3000/`. +It also provides account management options for creating pods and WebIDs to be used during authentication, +which are discussed more in-depth below. +The links on this page assume the server is hosted at `http://localhost:3000/`. ## Registering an account -To register an account, you can go to `http://localhost:3000/idp/register/` if this feature is enabled, -which it is on most configurations we provide. -Currently our registration page ties 3 features together on the same page: - -* Creating an account on the server. -* Creating or linking a WebID to your account. -* Creating a pod on the server. - -### Account - -To create an account you need to provide an email address and password. +To register an account, you can go to `http://localhost:3000/.account/password/register/`, if this feature is enabled. +There you can create an account with the email/password login method. The password will be salted and hashed before being stored. -As of now, the account is only used to log in and identify yourself to the IDP -when you want to do an authenticated request, -but in future the plan is to also use this for account/pod management. +Afterwards you will be redirected to the account page where you can create pods and link WebIDs to your account. -### WebID - -We require each account to have a corresponding WebID. -You can either let the server create a WebID for you in a pod, -which will also need to be created then, -or you can link an already existing WebID you have on an external server. - -In case you try to link your own WebID, you can choose if you want to be able -to use this server as your IDP for this WebID. -If not, you can still create a pod, -but you will not be able to direct the authentication client to this server to identify yourself. - -Additionally, if you try to register with an external WebID, -the first attempt will return an error indicating you need to add an identification triple to your WebID. -After doing that you can try to register again. -This is how we verify you are the owner of that WebID. -After registration the next page will inform you -that you have to add an additional triple to your WebID if you want to use the server as your IDP. - -All of the above is automated if you create the WebID on the server itself. - -### Pod +### Creating a pod To create a pod you simply have to fill in the name you want your pod to have. This will then be used to generate the full URL of your pod. @@ -57,23 +27,42 @@ For example, if you choose the name `test`, your pod would be located at `http://localhost:3000/test/` and your generated WebID would be `http://localhost:3000/test/profile/card#me`. +If you fill in a WebID when creating the pod, +that WebID will be the one that has access to all data in the pod. +If you don't, a WebID will be created in the pod and immediately linked to your account, +allowing you to use it for authentication and accessing the data in that pod + The generated name also depends on the configuration you chose for your server. If you are using the subdomain feature, -such as being done in the `config/memory-subdomains.json` configuration, the generated pod URL would be `http://test.localhost:3000/`. +### WebIDs + +To use Solid authentication, +you need to link at least one WebID to your account. +This can happen automatically when creating a pod as mentioned above, +or can be done manually with external WebIDs. + +If you try to link an external WebID, +the first attempt will return an error indicating you need to add an identification triple to your WebID. +After doing that you can try to register again. +This is how we verify you are the owner of that WebID. +Afterwards the page will inform you +that you have to add a triple to your WebID if you want to use the server as your IDP. + ## Logging in When using an authenticating client, you will be redirected to a login screen asking for your email and password. -After that you will be redirected to a page showing some basic information about the client. -There you need to consent that this client is allowed to identify using your WebID. +After that you will be redirected to a page showing some basic information about the client +where you can pick the WebID you want to use. +There you need to consent that this client is allowed to identify using that WebID. As a result the server will send a token back to the client that contains all the information needed to use your WebID. ## Forgot password -If you forgot your password, you can recover it by going to `http://localhost:3000/idp/forgotpassword/`. +If you forgot your password, you can recover it by going to `http://localhost:3000/.account/login/password/forgot/`. There you can enter your email address to get a recovery mail to reset your password. This feature only works if a mail server was configured, which by default is not the case. @@ -81,63 +70,11 @@ which by default is not the case. ## JSON API All of the above happens through HTML pages provided by the server. -By default, the server uses the templates found in `/templates/identity/email-password/` +By default, the server uses the templates found in `/templates/identity/` but different templates can be used through configuration. These templates all make use of a JSON API exposed by the server. -For example, when doing a GET request to `http://localhost:3000/idp/register/` -with a JSON accept header, the following JSON is returned: - -```json -{ - "required": { - "email": "string", - "password": "string", - "confirmPassword": "string", - "createWebId": "boolean", - "register": "boolean", - "createPod": "boolean", - "rootPod": "boolean" - }, - "optional": { - "webId": "string", - "podName": "string", - "template": "string" - }, - "controls": { - "register": "http://localhost:3000/idp/register/", - "index": "http://localhost:3000/idp/", - "prompt": "http://localhost:3000/idp/prompt/", - "login": "http://localhost:3000/idp/login/", - "forgotPassword": "http://localhost:3000/idp/forgotpassword/" - }, - "apiVersion": "0.3" -} -``` - -The `required` and `optional` fields indicate which input fields are expected by the API. -These correspond to the fields of the HTML registration page. -To register a user, you can do a POST request with a JSON body containing the correct fields: - -```json -{ - "email": "test@example.com", - "password": "secret", - "confirmPassword": "secret", - "createWebId": true, - "register": true, - "createPod": true, - "rootPod": false, - "podName": "test" -} -``` - -Two fields here that are not covered on the HTML page above are `rootPod` and `template`. -`rootPod` tells the server to put the pod in the root of the server instead of a location based on the `podName`. -By default the server will reject requests where this is `true`. -`template` is only used by servers running the `config/dynamic.json` configuration, -which is a very custom setup where every pod can have a different Components.js configuration, -so this value can usually be ignored. +A full description of this API can be found [here](account/json-api.md). ## IDP configuration @@ -175,14 +112,31 @@ which you will need to copy over to your base configuration and then remove the There is only one option here. This import contains all the core components necessary to make the IDP work. In case you need to make some changes to core IDP settings, this is where you would have to look. +### interaction + +Here you determine which features of account management are available. +`default.json` allows everything, while `no-accounts.json` and `no-pods.json` +disable account and pod creation respectively. +Taking one of those latter options will disable the relevant JSON APIs and HTML pages. + ### pod The `pod` options determines how pods are created. `static.json` is the expected pod behaviour as described above. `dynamic.json` is an experimental feature that allows users to have a custom Components.js configuration for their own pod. -When using such a setup, a JSON file will be written containing all the information of the user pods +When using such a configuration, a JSON file will be written containing all the information of the user pods, so they can be recreated when the server restarts. -### registration +## Adding a new login method to the server -This setting allows you to enable/disable registration on the server. +Due to its modular nature, +it is possible to add new login methods to the server, +allowing users to log in different ways than just the standard email/password combination. +More information on what is required can be found [here](account/login-method.md). + +## Data migration + +Going from v6 to v7 of the server, the account management is completely rewritten, +including how account data is stored on the server. +More information about how account data of an existing server can be migrated to the newer version +can be found [here](account/migration.md). diff --git a/documentation/markdown/usage/seeding-pods.md b/documentation/markdown/usage/seeding-pods.md index 35d47fb46..d2d0ec87a 100644 --- a/documentation/markdown/usage/seeding-pods.md +++ b/documentation/markdown/usage/seeding-pods.md @@ -1,39 +1,36 @@ # How to seed Accounts and Pods If you need to seed accounts and pods, -the `--seededPodConfigJson` command line option can be used +the `--seedConfig` command line option can be used with as value the path to a JSON file containing configurations for every required pod. The file needs to contain an array of JSON objects, -with each object containing at least a `podName`, `email`, and `password` field. +with each object containing at least an `email`, and `password` field. +Multiple pod objects can also be assigned to such an object in the `pods` array to create pods for the account, +with contents being the same as its corresponding JSON [API](account/json-api.md#controlsaccountpod). For example: ```json [ { - "podName": "example", "email": "hello@example.com", "password": "abc123" - } -] -``` - -You may optionally specify other parameters -as described in the [Identity Provider documentation](identity-provider.md#json-api). - -For example, to set up a pod without registering the generated WebID with the Identity Provider: - -```json -[ + }, { - "podName": "example", - "email": "hello@example.com", - "password": "abc123", - "webId": "https://id.inrupt.com/example", - "register": false + "email": "hello2@example.com", + "password": "123abc", + "pods": [ + { "name": "pod1" }, + { "name": "pod2" } + ] } ] ``` This feature cannot be used to register pods with pre-existing WebIDs, -which requires an interactive validation step. +which requires an interactive validation step, +unless you disable the WebID ownership check in your server configuration. + +Note that pod seeding is made for a default server setup with standard email/password login. +If you [add a new login method](account/login-method.md) +you will need to create a new implementation of pod seeding if you want to use it. diff --git a/documentation/markdown/usage/starting-server.md b/documentation/markdown/usage/starting-server.md index e1ac8fc8d..05ffc16fd 100644 --- a/documentation/markdown/usage/starting-server.md +++ b/documentation/markdown/usage/starting-server.md @@ -61,7 +61,7 @@ to some commonly used settings: | `--sparqlEndpoint, -s` | | URL of the SPARQL endpoint, when using a quadstore-based configuration. | | `--showStackTrace, -t` | false | Enables detailed logging on error output. | | `--podConfigJson` | `./pod-config.json` | Path to the file that keeps track of dynamic Pod configurations. Only relevant when using `@css:config/dynamic.json`. | -| `--seededPodConfigJson` | | Path to the file that keeps track of seeded Pod configurations. | +| `--seedConfig` | | Path to the file that keeps track of seeded account configurations. | | `--mainModulePath, -m` | | Path from where Components.js will start its lookup when initializing configurations. | | `--workers, -w` | `1` | Run in multithreaded mode using workers. Special values are `-1` (scale to `num_cores-1`), `0` (scale to `num_cores`) and 1 (singlethreaded). | diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index cfb52f689..ca45b3a07 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -80,10 +80,15 @@ nav: - Usage: - Example request: usage/example-requests.md - Metadata: usage/metadata.md - - Identity provider: usage/identity-provider.md + - Identity provider: + - Overview: usage/identity-provider.md + - JSON API: usage/account/json-api.md + - New login method: usage/account/login-method.md + - Data migration: usage/account/migration.md - Client credentials: usage/client-credentials.md - Seeding pods: usage/seeding-pods.md - Notifications: usage/notifications.md + - Development server: usage/dev-configuration.md - Architecture: - Overview: architecture/overview.md - Dependency injection: architecture/dependency-injection.md @@ -97,6 +102,10 @@ nav: - Parsing: architecture/features/protocol/parsing.md - Authorization: architecture/features/protocol/authorization.md - Resource Store: architecture/features/protocol/resource-store.md + - Account management: + - Overview: architecture/features/accounts/overview.md + - Controls: architecture/features/accounts/controls.md + - Routes: architecture/features/accounts/routes.md - Notifications: architecture/features/notifications.md - Contributing: - Pull requests: contributing/making-changes.md diff --git a/package-lock.json b/package-lock.json index 3a1aa145d..f3290ae4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@solid/access-token-verifier": "^2.0.5", "@types/async-lock": "^1.4.0", "@types/bcryptjs": "^2.4.2", + "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", "@types/ejs": "^3.1.2", "@types/end-of-stream": "^1.4.1", @@ -39,7 +40,8 @@ "arrayify-stream": "^2.0.1", "async-lock": "^1.4.0", "bcryptjs": "^2.4.3", - "componentsjs": "^5.3.2", + "componentsjs": "^5.4.2", + "cookie": "^0.4.2", "cors": "^2.8.5", "cross-fetch": "^4.0.0", "ejs": "^3.1.9", @@ -74,7 +76,8 @@ "winston": "^3.8.2", "winston-transport": "^4.5.0", "ws": "^8.13.0", - "yargs": "^17.7.1" + "yargs": "^17.7.1", + "yup": "^1.0.2" }, "bin": { "community-solid-server": "bin/server.js" @@ -4715,6 +4718,11 @@ "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.3.tgz", "integrity": "sha512-P1bffQfhD3O4LW0ioENXUhZ9OIa0Zn+P7M+pWgkCKaT53wVLSq0mrKksCID/FGHpFhRSxRGhgrQmfhRuzwtKdg==" }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" + }, "node_modules/@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", @@ -6670,18 +6678,18 @@ "dev": true }, "node_modules/componentsjs": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/componentsjs/-/componentsjs-5.3.2.tgz", - "integrity": "sha512-wqXaHjrnT4UDQT8Eaou/Itd55OWE7wasBivPJ0qfSlRMi5zRAwp3+sEgGO7F5T7hs0rMsrGTnkWWcoSHmrM/8A==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/componentsjs/-/componentsjs-5.4.2.tgz", + "integrity": "sha512-qIeXLozDkvubl6qtiovWsIBRqUP80w1ImTbilB6QE3OQgaEExI8pYZ9MkZ10QDFtdoKUryztlqp0AWs49t4puA==", "dependencies": { "@rdfjs/types": "*", "@types/minimist": "^1.2.0", - "@types/node": "^14.14.7", + "@types/node": "^18.0.0", "@types/semver": "^7.3.4", "jsonld-context-parser": "^2.1.1", "minimist": "^1.2.0", "rdf-data-factory": "^1.1.0", - "rdf-object": "^1.13.1", + "rdf-object": "^1.14.0", "rdf-parse": "^2.0.0", "rdf-quad": "^1.5.0", "rdf-string": "^1.6.0", @@ -6720,6 +6728,11 @@ "node": ">=12.0" } }, + "node_modules/componentsjs/node_modules/@types/node": { + "version": "18.18.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.3.tgz", + "integrity": "sha512-0OVfGupTl3NBFr8+iXpfZ8NR7jfFO+P1Q+IO/q0wbo02wYkP5gy36phojeYWpLQ6WAMjl+VfmqUk2YbUfp0irA==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -7223,6 +7236,14 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookiejar": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", @@ -13240,6 +13261,11 @@ "signal-exit": "^3.0.2" } }, + "node_modules/property-expr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz", + "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -13439,9 +13465,9 @@ } }, "node_modules/rdf-object": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/rdf-object/-/rdf-object-1.13.1.tgz", - "integrity": "sha512-Sgq+GbsqdPsMYh+d4OZ4C9brXlzqa9MvfVHG4pkuT9p7o+AX39nqjTWE/8HVaXjjOZBIDe8T54WWTMWphu3BpA==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/rdf-object/-/rdf-object-1.14.0.tgz", + "integrity": "sha512-/KSUWr7onDtL7d81kOpcUzJ2vHYOYJc2KU9WzBZRYydBhK0Sksh5Hg4VCQNaxUEvYEgdrrTuq9SLpOOCmag0rQ==", "dependencies": { "@rdfjs/types": "*", "jsonld-context-parser": "^2.0.2", @@ -14805,6 +14831,11 @@ "readable-stream": "3" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, "node_modules/tiny-glob": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", @@ -14850,6 +14881,11 @@ "node": ">=0.6" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/touch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", @@ -15599,6 +15635,28 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yup": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.1.1.tgz", + "integrity": "sha512-KfCGHdAErqFZWA5tZf7upSUnGKuTOnsI3hUsLr7fgVtx+DK04NPV01A68/FslI4t3s/ZWpvXJmgXhd7q6ICnag==", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } }, "dependencies": { @@ -19750,6 +19808,11 @@ "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.3.tgz", "integrity": "sha512-P1bffQfhD3O4LW0ioENXUhZ9OIa0Zn+P7M+pWgkCKaT53wVLSq0mrKksCID/FGHpFhRSxRGhgrQmfhRuzwtKdg==" }, + "@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" + }, "@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", @@ -21230,24 +21293,31 @@ "dev": true }, "componentsjs": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/componentsjs/-/componentsjs-5.3.2.tgz", - "integrity": "sha512-wqXaHjrnT4UDQT8Eaou/Itd55OWE7wasBivPJ0qfSlRMi5zRAwp3+sEgGO7F5T7hs0rMsrGTnkWWcoSHmrM/8A==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/componentsjs/-/componentsjs-5.4.2.tgz", + "integrity": "sha512-qIeXLozDkvubl6qtiovWsIBRqUP80w1ImTbilB6QE3OQgaEExI8pYZ9MkZ10QDFtdoKUryztlqp0AWs49t4puA==", "requires": { "@rdfjs/types": "*", "@types/minimist": "^1.2.0", - "@types/node": "^14.14.7", + "@types/node": "^18.0.0", "@types/semver": "^7.3.4", "jsonld-context-parser": "^2.1.1", "minimist": "^1.2.0", "rdf-data-factory": "^1.1.0", - "rdf-object": "^1.13.1", + "rdf-object": "^1.14.0", "rdf-parse": "^2.0.0", "rdf-quad": "^1.5.0", "rdf-string": "^1.6.0", "rdf-terms": "^1.7.0", "semver": "^7.3.2", "winston": "^3.3.3" + }, + "dependencies": { + "@types/node": { + "version": "18.18.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.3.tgz", + "integrity": "sha512-0OVfGupTl3NBFr8+iXpfZ8NR7jfFO+P1Q+IO/q0wbo02wYkP5gy36phojeYWpLQ6WAMjl+VfmqUk2YbUfp0irA==" + } } }, "componentsjs-generator": { @@ -21672,6 +21742,11 @@ } } }, + "cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" + }, "cookiejar": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", @@ -26214,6 +26289,11 @@ "signal-exit": "^3.0.2" } }, + "property-expr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz", + "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==" + }, "pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -26364,9 +26444,9 @@ } }, "rdf-object": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/rdf-object/-/rdf-object-1.13.1.tgz", - "integrity": "sha512-Sgq+GbsqdPsMYh+d4OZ4C9brXlzqa9MvfVHG4pkuT9p7o+AX39nqjTWE/8HVaXjjOZBIDe8T54WWTMWphu3BpA==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/rdf-object/-/rdf-object-1.14.0.tgz", + "integrity": "sha512-/KSUWr7onDtL7d81kOpcUzJ2vHYOYJc2KU9WzBZRYydBhK0Sksh5Hg4VCQNaxUEvYEgdrrTuq9SLpOOCmag0rQ==", "requires": { "@rdfjs/types": "*", "jsonld-context-parser": "^2.0.2", @@ -27468,6 +27548,11 @@ "readable-stream": "3" } }, + "tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, "tiny-glob": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", @@ -27504,6 +27589,11 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, + "toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "touch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", @@ -28033,6 +28123,24 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "yup": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.1.1.tgz", + "integrity": "sha512-KfCGHdAErqFZWA5tZf7upSUnGKuTOnsI3hUsLr7fgVtx+DK04NPV01A68/FslI4t3s/ZWpvXJmgXhd7q6ICnag==", + "requires": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + }, + "dependencies": { + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==" + } + } } } } diff --git a/package.json b/package.json index 90de22317..9bdd0acbf 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,8 @@ "release": "commit-and-tag-version", "postrelease": "ts-node ./scripts/finalizeRelease.ts", "start": "node ./bin/server.js", - "start:file": "node ./bin/server.js -c config/file.json -f ./data", - "start:file-root": "node ./bin/server.js -c config/file-root.json -f ./data", + "start:file": "node ./bin/server.js -c config/file.json -f ./.data", + "start:file-root": "node ./bin/server.js -c config/file-root.json -f ./.data", "test": "npm run test:ts && npm run jest", "test:deploy": "test/deploy/validate-configs.sh", "test:ts": "tsc -p test --noEmit", @@ -98,6 +98,7 @@ "@solid/access-token-verifier": "^2.0.5", "@types/async-lock": "^1.4.0", "@types/bcryptjs": "^2.4.2", + "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", "@types/ejs": "^3.1.2", "@types/end-of-stream": "^1.4.1", @@ -121,7 +122,8 @@ "arrayify-stream": "^2.0.1", "async-lock": "^1.4.0", "bcryptjs": "^2.4.3", - "componentsjs": "^5.3.2", + "componentsjs": "^5.4.2", + "cookie": "^0.4.2", "cors": "^2.8.5", "cross-fetch": "^4.0.0", "ejs": "^3.1.9", @@ -156,7 +158,8 @@ "winston": "^3.8.2", "winston-transport": "^4.5.0", "ws": "^8.13.0", - "yargs": "^17.7.1" + "yargs": "^17.7.1", + "yup": "^1.0.2" }, "devDependencies": { "@commitlint/cli": "^17.6.1", diff --git a/src/authorization/OwnerPermissionReader.ts b/src/authorization/OwnerPermissionReader.ts index f4ef27aba..7a2cd35d8 100644 --- a/src/authorization/OwnerPermissionReader.ts +++ b/src/authorization/OwnerPermissionReader.ts @@ -1,7 +1,8 @@ import type { Credentials } from '../authentication/Credentials'; import type { AuxiliaryIdentifierStrategy } from '../http/auxiliary/AuxiliaryIdentifierStrategy'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; -import type { AccountSettings, AccountStore } from '../identity/interaction/email-password/storage/AccountStore'; +import type { AccountStore } from '../identity/interaction/account/util/AccountStore'; +import type { WebIdStore } from '../identity/interaction/webid/util/WebIdStore'; import { getLoggerFor } from '../logging/LogUtil'; import { createErrorMessage } from '../util/errors/ErrorUtil'; import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; @@ -19,13 +20,15 @@ import type { PermissionMap } from './permissions/Permissions'; export class OwnerPermissionReader extends PermissionReader { protected readonly logger = getLoggerFor(this); + private readonly webIdStore: WebIdStore; private readonly accountStore: AccountStore; private readonly authStrategy: AuxiliaryIdentifierStrategy; private readonly identifierStrategy: IdentifierStrategy; - public constructor(accountStore: AccountStore, authStrategy: AuxiliaryIdentifierStrategy, + public constructor(webIdStore: WebIdStore, accountStore: AccountStore, authStrategy: AuxiliaryIdentifierStrategy, identifierStrategy: IdentifierStrategy) { super(); + this.webIdStore = webIdStore; this.accountStore = accountStore; this.authStrategy = authStrategy; this.identifierStrategy = identifierStrategy; @@ -40,16 +43,16 @@ export class OwnerPermissionReader extends PermissionReader { return result; } - let podBaseUrl: ResourceIdentifier; + let podBaseUrls: ResourceIdentifier[]; try { - podBaseUrl = await this.findPodBaseUrl(input.credentials); + podBaseUrls = await this.findPodBaseUrls(input.credentials); } catch (error: unknown) { this.logger.debug(`No pod owner Control permissions: ${createErrorMessage(error)}`); return result; } for (const auth of auths) { - if (this.identifierStrategy.contains(podBaseUrl, auth, true)) { + if (podBaseUrls.some((podBaseUrl): boolean => this.identifierStrategy.contains(podBaseUrl, auth, true))) { this.logger.debug(`Granting Control permissions to owner on ${auth.path}`); result.set(auth, { read: true, @@ -68,19 +71,26 @@ export class OwnerPermissionReader extends PermissionReader { * Find the base URL of the pod the given credentials own. * Will throw an error if none can be found. */ - private async findPodBaseUrl(credentials: Credentials): Promise { + private async findPodBaseUrls(credentials: Credentials): Promise { if (!credentials.agent?.webId) { throw new NotImplementedHttpError('Only authenticated agents could be owners'); } - let settings: AccountSettings; - try { - settings = await this.accountStore.getSettings(credentials.agent.webId); - } catch { - throw new NotImplementedHttpError('No account registered for this WebID'); + + const accountIds = await this.webIdStore.get(credentials.agent.webId); + if (accountIds.length === 0) { + throw new NotImplementedHttpError('No account is linked to this WebID'); } - if (!settings.podBaseUrl) { - throw new NotImplementedHttpError('This agent has no pod on the server'); + + const baseUrls: ResourceIdentifier[] = []; + for (const accountId of accountIds) { + const account = await this.accountStore.get(accountId); + if (!account) { + this.logger.error(`Found invalid account ID ${accountId} through WebID ${credentials.agent.webId}`); + continue; + } + baseUrls.push(...Object.keys(account.pods).map((pod): ResourceIdentifier => ({ path: pod }))); } - return { path: settings.podBaseUrl }; + + return baseUrls; } } diff --git a/src/http/input/metadata/AuthorizationParser.ts b/src/http/input/metadata/AuthorizationParser.ts new file mode 100644 index 000000000..89ebd008b --- /dev/null +++ b/src/http/input/metadata/AuthorizationParser.ts @@ -0,0 +1,43 @@ +import { DataFactory } from 'n3'; +import type { NamedNode } from 'rdf-js'; +import type { HttpRequest } from '../../../server/HttpRequest'; +import { matchesAuthorizationScheme } from '../../../util/HeaderUtil'; +import { SOLID_META } from '../../../util/Vocabularies'; +import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; +import { MetadataParser } from './MetadataParser'; +import namedNode = DataFactory.namedNode; + +/** + * Parses specific authorization schemes and stores their value as metadata. + * The keys of the input `authMap` should be the schemes, + * and the values the corresponding predicate that should be used to store the value in the metadata. + * The scheme will be sliced off the value, after which it is used as the object in the metadata triple. + * + * This should be used for custom authorization schemes, + * for things like OIDC tokens a {@link CredentialsExtractor} should be used. + */ +export class AuthorizationParser extends MetadataParser { + private readonly authMap: Record; + + public constructor(authMap: Record) { + super(); + this.authMap = Object.fromEntries( + Object.entries(authMap).map(([ scheme, uri ]): [string, NamedNode] => [ scheme, namedNode(uri) ]), + ); + } + + public async handle(input: { request: HttpRequest; metadata: RepresentationMetadata }): Promise { + const authHeader = input.request.headers.authorization; + if (!authHeader) { + return; + } + for (const [ scheme, uri ] of Object.entries(this.authMap)) { + if (matchesAuthorizationScheme(scheme, authHeader)) { + // This metadata should not be stored + input.metadata.add(uri, authHeader.slice(scheme.length + 1), SOLID_META.ResponseMetadata); + // There can only be 1 match + return; + } + } + } +} diff --git a/src/http/input/metadata/CookieParser.ts b/src/http/input/metadata/CookieParser.ts new file mode 100644 index 000000000..c3723cb66 --- /dev/null +++ b/src/http/input/metadata/CookieParser.ts @@ -0,0 +1,36 @@ +import { parse } from 'cookie'; +import { DataFactory } from 'n3'; +import type { NamedNode } from 'rdf-js'; +import type { HttpRequest } from '../../../server/HttpRequest'; +import { SOLID_META } from '../../../util/Vocabularies'; +import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; +import { MetadataParser } from './MetadataParser'; +import namedNode = DataFactory.namedNode; + +/** + * Parses the cookie header and stores their values as metadata. + * The keys of the input `cookieMap` should be the cookie names, + * and the values the corresponding predicate that should be used to store the value in the metadata. + * The values of the cookies will be used as objects in the generated triples + */ +export class CookieParser extends MetadataParser { + private readonly cookieMap: Record; + + public constructor(cookieMap: Record) { + super(); + this.cookieMap = Object.fromEntries( + Object.entries(cookieMap).map(([ name, uri ]): [string, NamedNode] => [ name, namedNode(uri) ]), + ); + } + + public async handle(input: { request: HttpRequest; metadata: RepresentationMetadata }): Promise { + const cookies = parse(input.request.headers.cookie ?? ''); + for (const [ name, uri ] of Object.entries(this.cookieMap)) { + const value = cookies[name]; + if (value) { + // This metadata should not be stored + input.metadata.add(uri, value, SOLID_META.ResponseMetadata); + } + } + } +} diff --git a/src/http/output/metadata/CookieMetadataWriter.ts b/src/http/output/metadata/CookieMetadataWriter.ts new file mode 100644 index 000000000..6f16906ce --- /dev/null +++ b/src/http/output/metadata/CookieMetadataWriter.ts @@ -0,0 +1,50 @@ +import { serialize } from 'cookie'; +import type { NamedNode } from 'n3'; +import { DataFactory } from 'n3'; +import type { HttpResponse } from '../../../server/HttpResponse'; +import { addHeader } from '../../../util/HeaderUtil'; +import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; +import { MetadataWriter } from './MetadataWriter'; + +/** + * Generates the necessary `Set-Cookie` header if a cookie value is detected in the metadata. + * The keys of the input `cookieMap` should be the URIs of the predicates + * used in the metadata when the object is a cookie value. + * The value of the map are objects that contain the name of the cookie, + * and the URI that is used to store the expiration date in the metadata, if any. + * If no expiration date is found in the metadata, none will be set for the cookie, + * causing it to be a session cookie. + */ +export class CookieMetadataWriter extends MetadataWriter { + private readonly cookieMap: Map; + + public constructor(cookieMap: Record) { + super(); + this.cookieMap = new Map(Object.entries(cookieMap) + .map(([ uri, { name, expirationUri }]): [ NamedNode, { name: string; expirationUri?: NamedNode } ] => + [ + DataFactory.namedNode(uri), + { + name, + expirationUri: expirationUri ? DataFactory.namedNode(expirationUri) : undefined, + }, + ])); + } + + public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise { + const { response, metadata } = input; + for (const [ uri, { name, expirationUri }] of this.cookieMap.entries()) { + const value = metadata.get(uri)?.value; + if (value) { + const expiration = expirationUri && metadata.get(expirationUri)?.value; + const expires = typeof expiration === 'string' ? new Date(expiration) : undefined; + // Not setting secure flag since not all tools realize those cookies are also valid for http://localhost. + // Not setting the httpOnly flag as that would prevent JS API access. + // SameSite: Lax makes it so the cookie gets sent if the origin is the server, + // or if the browser navigates there from another site. + // Setting the path to `/` so it applies to the entire server. + addHeader(response, 'Set-Cookie', serialize(name, value, { path: '/', sameSite: 'lax', expires })); + } + } + } +} diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index aa9f146ef..4706322ea 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -3,13 +3,11 @@ import type { ResponseDescription } from '../http/output/response/ResponseDescri import { getLoggerFor } from '../logging/LogUtil'; import type { OperationHttpHandlerInput } from '../server/OperationHttpHandler'; import { OperationHttpHandler } from '../server/OperationHttpHandler'; -import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter'; -import { APPLICATION_JSON } from '../util/ContentTypes'; +import { createErrorMessage } from '../util/errors/ErrorUtil'; +import { SOLID_HTTP } from '../util/Vocabularies'; import type { ProviderFactory } from './configuration/ProviderFactory'; -import type { - InteractionHandler, - Interaction, -} from './interaction/InteractionHandler'; +import type { CookieStore } from './interaction/account/util/CookieStore'; +import type { InteractionHandler, Interaction } from './interaction/InteractionHandler'; export interface IdentityProviderHttpHandlerArgs { /** @@ -17,9 +15,9 @@ export interface IdentityProviderHttpHandlerArgs { */ providerFactory: ProviderFactory; /** - * Used for converting the input data. + * Used to determine the account of the requesting agent. */ - converter: RepresentationConverter; + cookieStore: CookieStore; /** * Handles the requests. */ @@ -27,24 +25,22 @@ export interface IdentityProviderHttpHandlerArgs { } /** - * Generates the active Interaction object if there is an ongoing OIDC interaction - * and sends it to the {@link InteractionHandler}. + * Generates the active Interaction object if there is an ongoing OIDC interaction. + * Finds the account ID if there is cookie metadata. * - * Input data will first be converted to JSON. - * - * Only GET and POST methods are accepted. + * Calls the stored {@link InteractionHandler} with that information and returns the result. */ export class IdentityProviderHttpHandler extends OperationHttpHandler { protected readonly logger = getLoggerFor(this); private readonly providerFactory: ProviderFactory; - private readonly converter: RepresentationConverter; + private readonly cookieStore: CookieStore; private readonly handler: InteractionHandler; public constructor(args: IdentityProviderHttpHandlerArgs) { super(); this.providerFactory = args.providerFactory; - this.converter = args.converter; + this.cookieStore = args.cookieStore; this.handler = args.handler; } @@ -55,27 +51,18 @@ export class IdentityProviderHttpHandler extends OperationHttpHandler { const provider = await this.providerFactory.getProvider(); oidcInteraction = await provider.interactionDetails(request, response); this.logger.debug('Found an active OIDC interaction.'); - } catch { - this.logger.debug('No active OIDC interaction found.'); + } catch (error: unknown) { + this.logger.debug(`No active OIDC interaction found: ${createErrorMessage(error)}`); } - // Convert input data to JSON - // Allows us to still support form data - const { contentType } = operation.body.metadata; - if (contentType && contentType !== APPLICATION_JSON) { - this.logger.debug(`Converting input ${contentType} to ${APPLICATION_JSON}`); - const args = { - representation: operation.body, - preferences: { type: { [APPLICATION_JSON]: 1 }}, - identifier: operation.target, - }; - operation = { - ...operation, - body: await this.converter.handleSafe(args), - }; + // Determine account + let accountId: string | undefined; + const cookie = operation.body.metadata.get(SOLID_HTTP.terms.accountCookie)?.value; + if (cookie) { + accountId = await this.cookieStore.get(cookie); } - const representation = await this.handler.handleSafe({ operation, oidcInteraction }); + const representation = await this.handler.handleSafe({ operation, oidcInteraction, accountId }); return new OkResponseDescription(representation.metadata, representation.data); } } diff --git a/src/identity/IdentityUtil.ts b/src/identity/IdentityUtil.ts new file mode 100644 index 000000000..e99eea616 --- /dev/null +++ b/src/identity/IdentityUtil.ts @@ -0,0 +1,21 @@ +import type { CanBePromise } from '../../templates/types/oidc-provider'; + +/** + * Import the OIDC-provider package. + * + * As oidc-provider is an ESM package and CSS is CJS, we have to use a dynamic import here. + * Unfortunately, there is a Node/Jest bug that causes segmentation faults when doing such an import in Jest: + * https://github.com/nodejs/node/issues/35889 + * To work around that, we do the import differently, in case we are in a Jest test run. + * This can be detected via the env variables: https://jestjs.io/docs/environment-variables. + * There have been reports of `JEST_WORKER_ID` being undefined, so to be sure we check both. + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +export function importOidcProvider(): CanBePromise { + // eslint-disable-next-line no-process-env + if (process.env.JEST_WORKER_ID ?? process.env.NODE_ENV === 'test') { + // eslint-disable-next-line no-undef + return jest.requireActual('oidc-provider'); + } + return import('oidc-provider'); +} diff --git a/src/identity/configuration/AccountPromptFactory.ts b/src/identity/configuration/AccountPromptFactory.ts new file mode 100644 index 000000000..22eb7dc77 --- /dev/null +++ b/src/identity/configuration/AccountPromptFactory.ts @@ -0,0 +1,91 @@ +import type { interactionPolicy, KoaContextWithOIDC } from '../../../templates/types/oidc-provider'; +import { getLoggerFor } from '../../logging/LogUtil'; +import { InternalServerError } from '../../util/errors/InternalServerError'; +import { importOidcProvider } from '../IdentityUtil'; +import type { AccountStore } from '../interaction/account/util/AccountStore'; +import type { CookieStore } from '../interaction/account/util/CookieStore'; +import { ACCOUNT_PROMPT } from '../interaction/InteractionUtil'; +import { PromptFactory } from './PromptFactory'; + +type OIDCContext = NonNullable; +type ExtendedContext = OIDCContext & { internalAccountId?: string }; + +/** + * Creates the prompt necessary to ensure a user is logged in with their account when doing an OIDC interaction. + * This is done by checking the presence of the account-related cookie. + * + * Adds a Check to the login policy that verifies if the stored accountId, which corresponds to the chosen WebID, + * belongs to the currently logged in account. + */ +export class AccountPromptFactory extends PromptFactory { + protected readonly logger = getLoggerFor(this); + + private readonly accountStore: AccountStore; + private readonly cookieStore: CookieStore; + private readonly cookieName: string; + + public constructor(accountStore: AccountStore, cookieStore: CookieStore, cookieName: string) { + super(); + this.accountStore = accountStore; + this.cookieStore = cookieStore; + this.cookieName = cookieName; + } + + public async handle(policy: interactionPolicy.DefaultPolicy): Promise { + const { interactionPolicy: ip } = await importOidcProvider(); + this.addAccountPrompt(policy, ip); + this.addWebIdVerificationPrompt(policy, ip); + } + + private addAccountPrompt(policy: interactionPolicy.DefaultPolicy, ip: typeof interactionPolicy): void { + const check = new ip.Check('no_account', 'An account cookie is required.', async(ctx): Promise => { + const cookie = ctx.cookies.get(this.cookieName); + let accountId: string | undefined; + if (cookie) { + accountId = await this.cookieStore.get(cookie); + // This is an ugly way to pass a value to the other prompts/checks, + // but the oidc-provider library does similar things internally. + (ctx.oidc as ExtendedContext).internalAccountId = accountId; + } + this.logger.debug(`Found account cookie ${cookie} and accountID ${accountId}`); + + // Check needs to return true if the prompt has to trigger + return !accountId; + }); + const accountPrompt = new ip.Prompt({ name: ACCOUNT_PROMPT, requestable: true }, check); + policy.add(accountPrompt, 0); + } + + private addWebIdVerificationPrompt(policy: interactionPolicy.DefaultPolicy, ip: typeof interactionPolicy): void { + const check = new ip.Check('no_webid_ownserhip', + 'The stored WebID does not belong to the account.', + async(ctx): Promise => { + if (!ctx.oidc.session?.accountId) { + return false; + } + + const accountId = (ctx.oidc as ExtendedContext).internalAccountId; + if (!accountId) { + this.logger.error(`Missing 'internalAccountId' value in OIDC context`); + return false; + } + + const account = await this.accountStore.get(accountId); + if (!account) { + this.logger.error(`Invalid account ID ${accountId}`); + return false; + } + + const owner = account.webIds[ctx.oidc.session.accountId]; + this.logger.debug(`Session has WebID ${ctx.oidc.session.accountId + }, which ${owner ? 'belongs' : 'does not belong'} to the authenticated account`); + + return !owner; + }); + const loginPrompt = policy.get('login'); + if (!loginPrompt) { + throw new InternalServerError('Missing default login policy'); + } + loginPrompt.checks.add(check); + } +} diff --git a/src/identity/configuration/IdentityProviderFactory.ts b/src/identity/configuration/IdentityProviderFactory.ts index 1db9ed269..b33504111 100644 --- a/src/identity/configuration/IdentityProviderFactory.ts +++ b/src/identity/configuration/IdentityProviderFactory.ts @@ -12,27 +12,29 @@ import type { Account, UnknownObject, errors } from '../../../templates/types/oidc-provider'; import type Provider from '../../../templates/types/oidc-provider'; -import type { Operation } from '../../http/Operation'; import type { ErrorHandler } from '../../http/output/error/ErrorHandler'; import type { ResponseWriter } from '../../http/output/ResponseWriter'; -import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; import { getLoggerFor } from '../../logging/LogUtil'; import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; import type { HttpError } from '../../util/errors/HttpError'; import { errorTermsToMetadata } from '../../util/errors/HttpErrorUtil'; -import { InternalServerError } from '../../util/errors/InternalServerError'; import { OAuthHttpError } from '../../util/errors/OAuthHttpError'; -import { RedirectHttpError } from '../../util/errors/RedirectHttpError'; import { guardStream } from '../../util/GuardedStream'; import { joinUrl } from '../../util/PathUtil'; -import type { ClientCredentials } from '../interaction/email-password/credentials/ClientCredentialsAdapterFactory'; -import type { InteractionHandler } from '../interaction/InteractionHandler'; +import { importOidcProvider } from '../IdentityUtil'; +import type { ClientCredentialsStore } from '../interaction/client-credentials/util/ClientCredentialsStore'; +import type { InteractionRoute } from '../interaction/routing/InteractionRoute'; import type { AdapterFactory } from '../storage/AdapterFactory'; import type { AlgJwk, JwkGenerator } from './JwkGenerator'; +import type { PromptFactory } from './PromptFactory'; import type { ProviderFactory } from './ProviderFactory'; export interface IdentityProviderFactoryArgs { + /** + * Used to generate new prompt that are needed in addition to the defaults prompts. + */ + promptFactory: PromptFactory; /** * Factory that creates the adapter used for OIDC data storage. */ @@ -46,13 +48,13 @@ export interface IdentityProviderFactoryArgs { */ oidcPath: string; /** - * The handler responsible for redirecting interaction requests to the correct URL. + * The route where requests should be redirected to in case of an OIDC interaction. */ - interactionHandler: InteractionHandler; + interactionRoute: InteractionRoute; /** - * Storage containing the generated client credentials with their associated WebID. + * Store containing the generated client credentials with their associated WebID. */ - credentialStorage: KeyValueStorage; + clientCredentialsStore: ClientCredentialsStore; /** * Storage used to store cookie keys so they can be re-used in case of multithreading. */ @@ -87,12 +89,13 @@ const COOKIES_KEY = 'cookie-secret'; export class IdentityProviderFactory implements ProviderFactory { protected readonly logger = getLoggerFor(this); + private readonly promptFactory: PromptFactory; private readonly config: Configuration; private readonly adapterFactory: AdapterFactory; private readonly baseUrl: string; private readonly oidcPath: string; - private readonly interactionHandler: InteractionHandler; - private readonly credentialStorage: KeyValueStorage; + private readonly interactionRoute: InteractionRoute; + private readonly clientCredentialsStore: ClientCredentialsStore; private readonly storage: KeyValueStorage; private readonly jwkGenerator: JwkGenerator; private readonly showStackTrace: boolean; @@ -108,11 +111,12 @@ export class IdentityProviderFactory implements ProviderFactory { public constructor(config: Configuration, args: IdentityProviderFactoryArgs) { this.config = config; + this.promptFactory = args.promptFactory; this.adapterFactory = args.adapterFactory; this.baseUrl = args.baseUrl; this.oidcPath = args.oidcPath; - this.interactionHandler = args.interactionHandler; - this.credentialStorage = args.credentialStorage; + this.interactionRoute = args.interactionRoute; + this.clientCredentialsStore = args.clientCredentialsStore; this.storage = args.storage; this.jwkGenerator = args.jwkGenerator; this.showStackTrace = args.showStackTrace; @@ -145,21 +149,14 @@ export class IdentityProviderFactory implements ProviderFactory { // Render errors with our own error handler this.configureErrors(config); - // As oidc-provider is an ESM package and CSS is CJS, we have to use a dynamic import here. - // Unfortunately, there is a Node/Jest bug that causes segmentation faults when doing such an import in Jest: - // https://github.com/nodejs/node/issues/35889 - // To work around that, we do the import differently, in case we are in a Jest test run. - // This can be detected via the env variables: https://jestjs.io/docs/environment-variables. - // There have been reports of `JEST_WORKER_ID` being undefined, so to be sure we check both. - let ctr: { default: new(issuer: string, configuration?: Configuration) => Provider }; - // eslint-disable-next-line no-process-env - if (process.env.JEST_WORKER_ID ?? process.env.NODE_ENV === 'test') { - // eslint-disable-next-line no-undef - ctr = jest.requireActual('oidc-provider'); - } else { - ctr = await import('oidc-provider'); - } - const provider = new ctr.default(this.baseUrl, config); + const oidcImport = await importOidcProvider(); + + // Adds new prompts + const policy = oidcImport.interactionPolicy.base(); + await this.promptFactory.handleSafe(policy); + config.interactions!.policy = policy; + + const provider = new oidcImport.default(this.baseUrl, config); // Allow provider to interpret reverse proxy headers. provider.proxy = true; @@ -283,7 +280,7 @@ export class IdentityProviderFactory implements ProviderFactory { config.extraTokenClaims = async(ctx, token): Promise => this.isAccessToken(token) ? { webid: token.accountId } : - { webid: token.client && (await this.credentialStorage.get(token.client.clientId))?.webId }; + { webid: token.client && (await this.clientCredentialsStore.get(token.client.clientId))?.webId }; config.features = { ...config.features, @@ -300,7 +297,7 @@ export class IdentityProviderFactory implements ProviderFactory { getResourceServerInfo: (): ResourceServer => ({ // The scopes of the Resource Server. // These get checked when requesting client credentials. - scope: 'webid', + scope: '', audience: 'solid', accessTokenFormat: 'jwt', jwt: { @@ -329,26 +326,7 @@ export class IdentityProviderFactory implements ProviderFactory { // it will resolve the interactions.url helper function and redirect the User-Agent to that url. // Another requirement is that `features.userinfo` is disabled in the configuration. config.interactions = { - url: async(ctx, oidcInteraction): Promise => { - const operation: Operation = { - method: ctx.method, - target: { path: ctx.request.href }, - preferences: {}, - body: new BasicRepresentation(), - }; - - // Instead of sending a 3xx redirect to the client (via a RedirectHttpError), - // we need to pass the location URL to the OIDC library - try { - await this.interactionHandler.handleSafe({ operation, oidcInteraction }); - } catch (error: unknown) { - if (RedirectHttpError.isInstance(error)) { - return error.location; - } - throw error; - } - throw new InternalServerError('Could not correctly redirect for the given interaction.'); - }, + url: async(): Promise => this.interactionRoute.getPath(), }; config.routes = { diff --git a/src/identity/configuration/PromptFactory.ts b/src/identity/configuration/PromptFactory.ts new file mode 100644 index 000000000..22257a04f --- /dev/null +++ b/src/identity/configuration/PromptFactory.ts @@ -0,0 +1,7 @@ +import type { interactionPolicy } from '../../../templates/types/oidc-provider'; +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; + +/** + * Used to generate custom {@link interactionPolicy.Prompt}s. + */ +export abstract class PromptFactory extends AsyncHandler {} diff --git a/src/identity/interaction/BaseInteractionHandler.ts b/src/identity/interaction/BaseInteractionHandler.ts deleted file mode 100644 index 1f192c10a..000000000 --- a/src/identity/interaction/BaseInteractionHandler.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; -import type { Representation } from '../../http/representation/Representation'; -import { APPLICATION_JSON } from '../../util/ContentTypes'; -import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; -import type { InteractionHandlerInput } from './InteractionHandler'; -import { InteractionHandler } from './InteractionHandler'; - -/** - * Abstract implementation for handlers that always return a fixed JSON view on a GET. - * POST requests are passed to an abstract function. - * Other methods will be rejected. - */ -export abstract class BaseInteractionHandler extends InteractionHandler { - private readonly view: string; - - protected constructor(view: Record) { - super(); - this.view = JSON.stringify(view); - } - - public async canHandle(input: InteractionHandlerInput): Promise { - await super.canHandle(input); - const { method } = input.operation; - if (method !== 'GET' && method !== 'POST') { - throw new MethodNotAllowedHttpError([ method ], 'Only GET/POST requests are supported.'); - } - } - - public async handle(input: InteractionHandlerInput): Promise { - switch (input.operation.method) { - case 'GET': return this.handleGet(input); - case 'POST': return this.handlePost(input); - default: throw new MethodNotAllowedHttpError([ input.operation.method ]); - } - } - - /** - * Returns a fixed JSON view. - * @param input - Input parameters, only the operation target is used. - */ - protected async handleGet(input: InteractionHandlerInput): Promise { - return new BasicRepresentation(this.view, input.operation.target, APPLICATION_JSON); - } - - /** - * Function that will be called for POST requests. - * Input data remains unchanged. - * @param input - Input operation and OidcInteraction if it exists. - */ - protected abstract handlePost(input: InteractionHandlerInput): Promise; -} diff --git a/src/identity/interaction/ConsentHandler.ts b/src/identity/interaction/ConsentHandler.ts deleted file mode 100644 index 140317f26..000000000 --- a/src/identity/interaction/ConsentHandler.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { - AllClientMetadata, - InteractionResults, - KoaContextWithOIDC, - UnknownObject, -} from '../../../templates/types/oidc-provider'; -import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; -import type { Representation } from '../../http/representation/Representation'; -import { APPLICATION_JSON } from '../../util/ContentTypes'; -import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; -import { FoundHttpError } from '../../util/errors/FoundHttpError'; -import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; -import { readJsonStream } from '../../util/StreamUtil'; -import type { ProviderFactory } from '../configuration/ProviderFactory'; -import { BaseInteractionHandler } from './BaseInteractionHandler'; -import type { Interaction, InteractionHandlerInput } from './InteractionHandler'; - -type Grant = NonNullable; - -/** - * Handles the OIDC consent prompts where the user confirms they want to log in for the given client. - * - * Returns all the relevant Client metadata on GET requests. - */ -export class ConsentHandler extends BaseInteractionHandler { - private readonly providerFactory: ProviderFactory; - - public constructor(providerFactory: ProviderFactory) { - super({}); - this.providerFactory = providerFactory; - } - - public async canHandle(input: InteractionHandlerInput): Promise { - await super.canHandle(input); - if (input.operation.method === 'POST' && !input.oidcInteraction) { - throw new BadRequestHttpError( - 'This action can only be performed as part of an OIDC authentication flow.', - { errorCode: 'E0002' }, - ); - } - } - - protected async handleGet(input: Required): Promise { - const { operation, oidcInteraction } = input; - const provider = await this.providerFactory.getProvider(); - const client = await provider.Client.find(oidcInteraction.params.client_id as string); - const metadata: AllClientMetadata = client?.metadata() ?? {}; - - // Only extract specific fields to prevent leaking information - // Based on https://www.w3.org/ns/solid/oidc-context.jsonld - const keys = [ 'client_id', 'client_uri', 'logo_uri', 'policy_uri', - 'client_name', 'contacts', 'grant_types', 'scope' ]; - - const jsonLd = Object.fromEntries( - keys.filter((key): boolean => key in metadata) - .map((key): [ string, unknown ] => [ key, metadata[key] ]), - ); - jsonLd['@context'] = 'https://www.w3.org/ns/solid/oidc-context.jsonld'; - const json = { webId: oidcInteraction.session?.accountId, client: jsonLd }; - - return new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON); - } - - protected async handlePost({ operation, oidcInteraction }: InteractionHandlerInput): Promise { - const { remember, logOut } = await readJsonStream(operation.body.data); - - if (logOut) { - const provider = await this.providerFactory.getProvider(); - const session = (await provider.Session.find(oidcInteraction!.session!.cookie))!; - delete session.accountId; - await session.save(session.exp - Math.floor(Date.now() / 1000)); - - throw new FoundHttpError(oidcInteraction!.returnTo); - } - - const grant = await this.getGrant(oidcInteraction!); - this.updateGrant(grant, oidcInteraction!.prompt.details, remember); - - const location = await this.updateInteraction(oidcInteraction!, grant); - - throw new FoundHttpError(location); - } - - /** - * Either returns the grant associated with the given interaction or creates a new one if it does not exist yet. - */ - private async getGrant(oidcInteraction: Interaction): Promise { - if (!oidcInteraction.session) { - throw new NotImplementedHttpError('Only interactions with a valid session are supported.'); - } - - const { params, session: { accountId }, grantId } = oidcInteraction; - const provider = await this.providerFactory.getProvider(); - let grant: Grant; - if (grantId) { - grant = (await provider.Grant.find(grantId))!; - } else { - grant = new provider.Grant({ - accountId, - clientId: params.client_id as string, - }); - } - return grant; - } - - /** - * Updates the grant with all the missing scopes and claims requested by the interaction. - * - * Will reject the `offline_access` scope if `remember` is false. - */ - private updateGrant(grant: Grant, details: UnknownObject, remember: boolean): void { - // Reject the offline_access scope if the user does not want to be remembered - if (!remember) { - grant.rejectOIDCScope('offline_access'); - } - - // Grant all the requested scopes and claims - if (details.missingOIDCScope) { - grant.addOIDCScope((details.missingOIDCScope as string[]).join(' ')); - } - if (details.missingOIDCClaims) { - grant.addOIDCClaims(details.missingOIDCClaims as string[]); - } - if (details.missingResourceScopes) { - for (const [ indicator, scopes ] of Object.entries(details.missingResourceScopes as Record)) { - grant.addResourceScope(indicator, scopes.join(' ')); - } - } - } - - /** - * Updates the interaction with the new grant and returns the resulting redirect URL. - */ - private async updateInteraction(oidcInteraction: Interaction, grant: Grant): Promise { - const grantId = await grant.save(); - - const consent: InteractionResults['consent'] = {}; - // Only need to update the grantId if it is new - if (!oidcInteraction.grantId) { - consent.grantId = grantId; - } - - const result: InteractionResults = { consent }; - - // Need to merge with previous submission - oidcInteraction.result = { ...oidcInteraction.lastSubmission, ...result }; - await oidcInteraction.save(oidcInteraction.exp - Math.floor(Date.now() / 1000)); - - return oidcInteraction.returnTo; - } -} diff --git a/src/identity/interaction/ControlHandler.ts b/src/identity/interaction/ControlHandler.ts index 97b26d8ef..390fea28b 100644 --- a/src/identity/interaction/ControlHandler.ts +++ b/src/identity/interaction/ControlHandler.ts @@ -1,43 +1,118 @@ -import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; -import type { Representation } from '../../http/representation/Representation'; -import { APPLICATION_JSON } from '../../util/ContentTypes'; -import { InternalServerError } from '../../util/errors/InternalServerError'; -import { readJsonStream } from '../../util/StreamUtil'; -import type { InteractionHandlerInput } from './InteractionHandler'; -import { InteractionHandler } from './InteractionHandler'; +import { ACCOUNT_ID_KEY } from './account/AccountIdRoute'; +import type { Json, JsonRepresentation } from './InteractionUtil'; +import type { JsonInteractionHandlerInput } from './JsonInteractionHandler'; +import { JsonInteractionHandler } from './JsonInteractionHandler'; import type { InteractionRoute } from './routing/InteractionRoute'; - -const INTERNAL_API_VERSION = '0.4'; +import Dict = NodeJS.Dict; /** - * Adds `controls` and `apiVersion` fields to the output of its source handler, - * such that clients can predictably find their way to other resources. - * Control paths are determined by the input routes. + * Creates an object with the keys matching those of the input `controls`, + * and the values being the results received by the matching values in the same input. + * + * If `source` is defined, the controls will be added to the output of that handler after passing the input. + * In case the control keys conflict with a key already present in the resulting object, + * the results will be merged. */ -export class ControlHandler extends InteractionHandler { - private readonly source: InteractionHandler; - private readonly controls: Record; +export class ControlHandler extends JsonInteractionHandler { + private readonly controls: Record; + private readonly source?: JsonInteractionHandler; - public constructor(source: InteractionHandler, controls: Record) { + public constructor(controls: Record, + source?: JsonInteractionHandler) { super(); + this.controls = controls; this.source = source; - this.controls = Object.fromEntries( - Object.entries(controls).map(([ control, route ]): [ string, string ] => [ control, route.getPath() ]), - ); } - public async canHandle(input: InteractionHandlerInput): Promise { - await this.source.canHandle(input); + public async canHandle(input: JsonInteractionHandlerInput): Promise { + await this.source?.canHandle(input); } - public async handle(input: InteractionHandlerInput): Promise { - const result = await this.source.handle(input); - if (result.metadata.contentType !== APPLICATION_JSON) { - throw new InternalServerError('Source handler should return application/json.'); + public async handle(input: JsonInteractionHandlerInput): Promise { + const result = await this.source?.handle(input); + const controls = await this.generateControls(input); + + const json = this.mergeControls(result?.json, controls) as Dict; + + return { + json, + metadata: result?.metadata, + }; + } + + protected isRoute(value: InteractionRoute | JsonInteractionHandler): value is InteractionRoute { + return Boolean((value as InteractionRoute).getPath); + } + + /** + * Generate the controls for all the stored keys. + */ + protected async generateControls(input: JsonInteractionHandlerInput): Promise> { + let controls: Record = {}; + + for (const [ key, value ] of Object.entries(this.controls)) { + const controlSet = await this.generateControlSet(input, value); + if (controlSet) { + controls = this.mergeControls(controls, { [key]: controlSet }) as Record; + } } - const json = await readJsonStream(result.data); - json.controls = this.controls; - json.apiVersion = INTERNAL_API_VERSION; - return new BasicRepresentation(JSON.stringify(json), result.metadata); + + return controls; + } + + protected async generateControlSet(input: JsonInteractionHandlerInput, + value: InteractionRoute | JsonInteractionHandler): Promise { + if (this.isRoute(value)) { + try { + return value.getPath({ [ACCOUNT_ID_KEY]: input.accountId }); + } catch { + // Path required an account ID which is missing + return; + } + } + const { json } = await value.handleSafe(input); + if (Array.isArray(json) && json.length === 0) { + return; + } + if (typeof json === 'object' && Object.keys(json).length === 0) { + return; + } + return json; + } + + /** + * Merge the two objects. + * Generally this will probably not be necessary, or be very simple merges, + * but this ensures that we handle all possibilities. + */ + protected mergeControls(original?: Json, controls?: Json): Json { + if (typeof original === 'undefined') { + return controls!; + } + + if (typeof controls === 'undefined') { + return original; + } + + if (typeof original !== 'object' || typeof controls !== 'object') { + return original; + } + + if (Array.isArray(original)) { + if (Array.isArray(controls)) { + return [ ...original, ...controls ]; + } + return original; + } + + if (Array.isArray(controls)) { + return original; + } + + const result: Record = {}; + for (const key of new Set([ ...Object.keys(original), ...Object.keys(controls) ])) { + result[key] = this.mergeControls(original[key], controls[key]); + } + return result; } } diff --git a/src/identity/interaction/CookieInteractionHandler.ts b/src/identity/interaction/CookieInteractionHandler.ts new file mode 100644 index 000000000..5b2c45b82 --- /dev/null +++ b/src/identity/interaction/CookieInteractionHandler.ts @@ -0,0 +1,70 @@ +import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import { SOLID_HTTP } from '../../util/Vocabularies'; +import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from './account/util/Account'; +import type { AccountStore } from './account/util/AccountStore'; +import type { CookieStore } from './account/util/CookieStore'; +import type { JsonRepresentation } from './InteractionUtil'; +import type { JsonInteractionHandlerInput } from './JsonInteractionHandler'; +import { JsonInteractionHandler } from './JsonInteractionHandler'; + +/** + * Handles all the necessary steps for having cookies. + * Refreshes the cookie expiration if there was a successful account interaction. + * Adds the cookie and cookie expiration data to the output metadata, + * unless it is already present in that metadata. + * Checks the account settings to see if the cookie needs to be remembered. + */ +export class CookieInteractionHandler extends JsonInteractionHandler { + private readonly source: JsonInteractionHandler; + private readonly accountStore: AccountStore; + private readonly cookieStore: CookieStore; + + public constructor(source: JsonInteractionHandler, accountStore: AccountStore, cookieStore: CookieStore) { + super(); + this.source = source; + this.accountStore = accountStore; + this.cookieStore = cookieStore; + } + + public async canHandle(input: JsonInteractionHandlerInput): Promise { + return this.source.canHandle(input); + } + + public async handle(input: JsonInteractionHandlerInput): Promise { + const output = await this.source.handle(input); + + let { metadata: outputMetadata } = output; + + // The cookie could be new, in the output, or the one received in the input if no new cookie is made + const cookie = outputMetadata?.get(SOLID_HTTP.terms.accountCookie)?.value ?? + input.metadata.get(SOLID_HTTP.terms.accountCookie)?.value; + // Only update the expiration if it wasn't set by the source handler, + // as that might have a specific reason, such as logging out. + if (!cookie || outputMetadata?.has(SOLID_HTTP.terms.accountCookieExpiration)) { + return output; + } + // Not reusing the account ID from the input, + // as that could potentially belong to a different account if this is a new login action. + const accountId = await this.cookieStore.get(cookie); + + // Only refresh the cookie if it points to an account that exists and wants to be remembered + if (!accountId) { + return output; + } + const account = await this.accountStore.get(accountId); + if (!account?.settings[ACCOUNT_SETTINGS_REMEMBER_LOGIN]) { + return output; + } + + // Refresh the cookie, could be undefined if it was deleted by the operation + const expiration = await this.cookieStore.refresh(cookie); + if (expiration) { + outputMetadata = outputMetadata ?? new RepresentationMetadata(input.target); + outputMetadata.set(SOLID_HTTP.terms.accountCookie, cookie); + outputMetadata.set(SOLID_HTTP.terms.accountCookieExpiration, expiration.toISOString()); + output.metadata = outputMetadata; + } + + return output; + } +} diff --git a/src/identity/interaction/FixedInteractionHandler.ts b/src/identity/interaction/FixedInteractionHandler.ts deleted file mode 100644 index 5237de868..000000000 --- a/src/identity/interaction/FixedInteractionHandler.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable tsdoc/syntax */ -// tsdoc/syntax cannot handle `@range` -import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; -import type { Representation } from '../../http/representation/Representation'; -import { APPLICATION_JSON } from '../../util/ContentTypes'; -import type { InteractionHandlerInput } from './InteractionHandler'; -import { InteractionHandler } from './InteractionHandler'; - -/** - * An {@link InteractionHandler} that always returns the same JSON response on all requests. - */ -export class FixedInteractionHandler extends InteractionHandler { - private readonly response: string; - - /** - * @param response - @range {json} - */ - public constructor(response: Record) { - super(); - this.response = JSON.stringify(response); - } - - public async handle({ operation }: InteractionHandlerInput): Promise { - return new BasicRepresentation(this.response, operation.target, APPLICATION_JSON); - } -} diff --git a/src/identity/interaction/HtmlViewHandler.ts b/src/identity/interaction/HtmlViewHandler.ts index 76cd59c8a..ea567ca77 100644 --- a/src/identity/interaction/HtmlViewHandler.ts +++ b/src/identity/interaction/HtmlViewHandler.ts @@ -10,14 +10,26 @@ import type { InteractionHandlerInput } from './InteractionHandler'; import { InteractionHandler } from './InteractionHandler'; import type { InteractionRoute } from './routing/InteractionRoute'; +/** + * Used to link file paths and URLs together. + * The reason we use a separate object instead of a key/value Record, + * is that this makes it easier to override the values in Components.js, + * which can be useful if someone wants to replace the HTML for certain URLs. + */ +export class HtmlViewEntry { + public constructor( + public readonly route: InteractionRoute, + public readonly filePath: string, + ) { } +} + /** * Stores the HTML templates associated with specific InteractionRoutes. - * Template keys should be file paths to the templates, - * values should be the corresponding routes. * - * Will only handle GET operations for which there is a matching template if HTML is more preferred than JSON. - * Reason for doing it like this instead of a standard content negotiation flow - * is because we only want to return the HTML pages on GET requests. * + * This class will only handle GET operations for which there is a matching template, + * if HTML is more preferred than JSON. + * The reason for doing it like this instead of a standard content negotiation flow, + * is because we only want to return the HTML pages on GET requests. * * Templates will receive the parameter `idpIndex` in their context pointing to the root index URL of the IDP API * and an `authenticating` parameter indicating if this is an active OIDC interaction. @@ -25,37 +37,47 @@ import type { InteractionRoute } from './routing/InteractionRoute'; export class HtmlViewHandler extends InteractionHandler { private readonly idpIndex: string; private readonly templateEngine: TemplateEngine; - private readonly templates: Record; + private readonly templates: HtmlViewEntry[]; - public constructor(index: InteractionRoute, templateEngine: TemplateEngine, - templates: Record) { + public constructor(index: InteractionRoute, templateEngine: TemplateEngine, templates: HtmlViewEntry[]) { super(); this.idpIndex = index.getPath(); this.templateEngine = templateEngine; - this.templates = Object.fromEntries( - Object.entries(templates).map(([ template, route ]): [ string, string ] => [ route.getPath(), template ]), - ); + this.templates = templates; } public async canHandle({ operation }: InteractionHandlerInput): Promise { if (operation.method !== 'GET') { throw new MethodNotAllowedHttpError([ operation.method ]); } - if (!this.templates[operation.target.path]) { - throw new NotFoundHttpError(); - } + const preferences = cleanPreferences(operation.preferences.type); const htmlWeight = getTypeWeight(TEXT_HTML, preferences); const jsonWeight = getTypeWeight(APPLICATION_JSON, preferences); if (jsonWeight >= htmlWeight) { throw new NotImplementedHttpError('HTML views are only returned when they are preferred.'); } + + // Will throw error if no match is found + this.findTemplate(operation.target.path); } public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise { - const template = this.templates[operation.target.path]; + const template = this.findTemplate(operation.target.path); const contents = { idpIndex: this.idpIndex, authenticating: Boolean(oidcInteraction) }; const result = await this.templateEngine.handleSafe({ contents, template: { templateFile: template }}); return new BasicRepresentation(result, operation.target, TEXT_HTML); } + + /** + * Finds the template for the given URL. + */ + private findTemplate(target: string): string { + for (const template of this.templates) { + if (template.route.matchPath(target)) { + return template.filePath; + } + } + throw new NotFoundHttpError(); + } } diff --git a/src/identity/interaction/InteractionHandler.ts b/src/identity/interaction/InteractionHandler.ts index b044df895..7964a6a18 100644 --- a/src/identity/interaction/InteractionHandler.ts +++ b/src/identity/interaction/InteractionHandler.ts @@ -1,8 +1,6 @@ import type { KoaContextWithOIDC } from '../../../templates/types/oidc-provider'; import type { Operation } from '../../http/Operation'; import type { Representation } from '../../http/representation/Representation'; -import { APPLICATION_JSON } from '../../util/ContentTypes'; -import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { AsyncHandler } from '../../util/handlers/AsyncHandler'; // OIDC library does not directly export the Interaction type @@ -18,17 +16,13 @@ export interface InteractionHandlerInput { * such as logging a user in. */ oidcInteraction?: Interaction; + /** + * The account id of the agent doing the request if one could be found. + */ + accountId?: string; } /** * Handler used for IDP interactions. - * Only supports JSON data. */ -export abstract class InteractionHandler extends AsyncHandler { - public async canHandle({ operation }: InteractionHandlerInput): Promise { - const { contentType } = operation.body.metadata; - if (contentType && contentType !== APPLICATION_JSON) { - throw new NotImplementedHttpError('Only application/json data is supported.'); - } - } -} +export abstract class InteractionHandler extends AsyncHandler { } diff --git a/src/identity/interaction/InteractionUtil.ts b/src/identity/interaction/InteractionUtil.ts new file mode 100644 index 000000000..c86481e0e --- /dev/null +++ b/src/identity/interaction/InteractionUtil.ts @@ -0,0 +1,86 @@ +import type { InteractionResults } from '../../../templates/types/oidc-provider'; +import type Provider from '../../../templates/types/oidc-provider'; +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import { getLoggerFor } from '../../logging/LogUtil'; +import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; +import type { Interaction } from './InteractionHandler'; +import Dict = NodeJS.Dict; + +const logger = getLoggerFor('AccountUtil'); + +/** + * A JSON object. + */ +export type Json = string | number | boolean | Dict | Json[]; + +/** + * Contains a JSON object and any associated metadata. + * Similar to a {@link Representation} but with all the data in memory instead of as a stream + * and specific to JSON. + */ +export interface JsonRepresentation = Dict> { + json: T; + metadata?: RepresentationMetadata; +} + +/** + * Asserts `oidcInteraction` is defined, throws the correct error in case this is not the case. + * The error contains the relevant error code that can be used to explain more extensively what the issue is + * and why an OIDC interaction is needed. + * + * @param oidcInteraction - Interaction object to check. + */ +export function assertOidcInteraction(oidcInteraction?: Interaction): asserts oidcInteraction is Interaction { + if (!oidcInteraction) { + logger.warn(`Trying to perform OIDC operation without being in an OIDC authentication flow`); + throw new BadRequestHttpError( + 'This action can only be performed as part of an OIDC authentication flow.', + { errorCode: 'E0002' }, + ); + } +} + +/** + * The prompt that is used to track the account ID of a user during an OIDC interaction. + * The already existing `login` prompt in the {@link InteractionResults} + * is used to track the WebID that is chosen in an OIDC interaction. + */ +export const ACCOUNT_PROMPT = 'account'; +/** + * {@link InteractionResults} extended with our custom key for tracking a user's account ID. + */ +export type AccountInteractionResults = { [ACCOUNT_PROMPT]?: string } & InteractionResults; + +/** + * Updates the `oidcInteraction` object with the necessary data in case a prompt gets updated. + * @param oidcInteraction - Interaction to update. + * @param result - New data to add to the interaction. + * @param mergeWithLastSubmission - If this new data needs to be merged with already existing data in the interaction. + */ +export async function finishInteraction(oidcInteraction: Interaction, result: AccountInteractionResults, + mergeWithLastSubmission: boolean): Promise { + if (mergeWithLastSubmission) { + result = { ...oidcInteraction.lastSubmission, ...result }; + } + + oidcInteraction.result = result; + await oidcInteraction.persist(); + + return oidcInteraction.returnTo; +} + +/** + * Removes the WebID, the `accountId`, from the OIDC session object, + * allowing us to replace it with a new value. + * If there is no session in the Interaction, nothing will happen. + * @param provider - The OIDC provider. + * @param oidcInteraction - The current interaction. + */ +export async function forgetWebId(provider: Provider, oidcInteraction: Interaction): Promise { + if (oidcInteraction.session) { + const session = (await provider.Session.find(oidcInteraction.session.cookie))!; + logger.debug(`Forgetting WebID ${session.accountId} in active session`); + delete session.accountId; + await session.persist(); + } +} diff --git a/src/identity/interaction/JsonConversionHandler.ts b/src/identity/interaction/JsonConversionHandler.ts new file mode 100644 index 000000000..80af8ada1 --- /dev/null +++ b/src/identity/interaction/JsonConversionHandler.ts @@ -0,0 +1,72 @@ +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; +import type { Representation } from '../../http/representation/Representation'; +import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; +import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { readJsonStream } from '../../util/StreamUtil'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; +import type { Json } from './InteractionUtil'; +import type { JsonInteractionHandler, JsonInteractionHandlerInput } from './JsonInteractionHandler'; + +/** + * An {@link InteractionHandler} that sits in-between + * an {@link InteractionHandler} and a {@link JsonInteractionHandler}. + * It converts the input data stream into a JSON object to be used by the stored handler. + * + * Since the JSON body is only made during the `handle` call, it can not be used during the `canHandle`, + * so the `canHandle` call of the stored handler is not called, + * meaning this class accepts all input that can be converted to JSON. + */ +export class JsonConversionHandler extends InteractionHandler { + private readonly source: JsonInteractionHandler; + private readonly converter: RepresentationConverter; + + public constructor(source: JsonInteractionHandler, converter: RepresentationConverter) { + super(); + this.source = source; + this.converter = converter; + } + + public async canHandle({ operation }: InteractionHandlerInput): Promise { + if (!operation.body.isEmpty) { + await this.converter.canHandle({ + identifier: operation.target, + preferences: { type: { [APPLICATION_JSON]: 1 }}, + representation: operation.body, + }); + } + } + + public async handle({ operation, oidcInteraction, accountId }: InteractionHandlerInput): Promise { + let json: Json = {}; + let jsonMetadata = operation.body.metadata; + + // Convert to JSON and read out if there is a body + if (!operation.body.isEmpty) { + const converted = await this.converter.handle({ + identifier: operation.target, + preferences: { type: { [APPLICATION_JSON]: 1 }}, + representation: operation.body, + }); + json = await readJsonStream(converted.data); + jsonMetadata = converted.metadata; + } + + // Input for the handler + const input: JsonInteractionHandlerInput = { + method: operation.method, + target: operation.target, + metadata: jsonMetadata, + json, + oidcInteraction, + accountId, + }; + + const result = await this.source.handleSafe(input); + + // Convert the response JSON back to a Representation + const responseMetadata = result.metadata ?? new RepresentationMetadata(operation.target); + return new BasicRepresentation(JSON.stringify(result.json), responseMetadata, APPLICATION_JSON); + } +} diff --git a/src/identity/interaction/JsonInteractionHandler.ts b/src/identity/interaction/JsonInteractionHandler.ts new file mode 100644 index 000000000..e2e1e65eb --- /dev/null +++ b/src/identity/interaction/JsonInteractionHandler.ts @@ -0,0 +1,41 @@ +import type { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; +import { AsyncHandler } from '../../util/handlers/AsyncHandler'; +import type { Interaction } from './InteractionHandler'; +import type { Json, JsonRepresentation } from './InteractionUtil'; +import Dict = NodeJS.Dict; + +export interface JsonInteractionHandlerInput { + /** + * The operation to execute. + */ + method: string; + /** + * The resource that is being targeted. + */ + target: ResourceIdentifier; + /** + * The JSON body of the request. + */ + json: unknown; + /** + * The metadata of the request. + */ + metadata: RepresentationMetadata; + /** + * Will be defined if the OIDC library expects us to resolve an interaction it can't handle itself, + * such as logging a user in. + */ + oidcInteraction?: Interaction; + /** + * The account id of the agent doing the request if one could be found. + */ + accountId?: string; +} + +/** + * A handler that consumes and returns a JSON object, + * designed to be used for IDP/OIDC interactions. + */ +export abstract class JsonInteractionHandler = Dict> + extends AsyncHandler> { } diff --git a/src/identity/interaction/JsonView.ts b/src/identity/interaction/JsonView.ts new file mode 100644 index 000000000..e7cb28602 --- /dev/null +++ b/src/identity/interaction/JsonView.ts @@ -0,0 +1,10 @@ +import type { JsonRepresentation } from './InteractionUtil'; +import type { JsonInteractionHandlerInput } from './JsonInteractionHandler'; + +/** + * An interface that can be used by classes that can provide a view besides doing an action. + * Designed to be used by a {@link JsonInteractionHandler} that has a view explaining what JSON input it supports. + */ +export interface JsonView { + getView: (input: JsonInteractionHandlerInput) => Promise; +} diff --git a/src/identity/interaction/LocationInteractionHandler.ts b/src/identity/interaction/LocationInteractionHandler.ts index 392307139..02b6ddb31 100644 --- a/src/identity/interaction/LocationInteractionHandler.ts +++ b/src/identity/interaction/LocationInteractionHandler.ts @@ -1,9 +1,10 @@ -import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; -import type { Representation } from '../../http/representation/Representation'; -import { APPLICATION_JSON } from '../../util/ContentTypes'; +import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata'; +import { getLoggerFor } from '../../logging/LogUtil'; import { RedirectHttpError } from '../../util/errors/RedirectHttpError'; -import type { InteractionHandlerInput } from './InteractionHandler'; -import { InteractionHandler } from './InteractionHandler'; +import { SOLID_HTTP } from '../../util/Vocabularies'; +import type { JsonRepresentation } from './InteractionUtil'; +import type { JsonInteractionHandlerInput } from './JsonInteractionHandler'; +import { JsonInteractionHandler } from './JsonInteractionHandler'; /** * Transforms an HTTP redirect into a hypermedia document with a redirection link, @@ -22,25 +23,29 @@ import { InteractionHandler } from './InteractionHandler'; * with an explicit link to the next page, * enabling the script to move the user to the next page. */ -export class LocationInteractionHandler extends InteractionHandler { - private readonly source: InteractionHandler; +export class LocationInteractionHandler extends JsonInteractionHandler { + private readonly logger = getLoggerFor(this); - public constructor(source: InteractionHandler) { + private readonly source: JsonInteractionHandler; + + public constructor(source: JsonInteractionHandler) { super(); this.source = source; } - public async canHandle(input: InteractionHandlerInput): Promise { + public async canHandle(input: JsonInteractionHandlerInput): Promise { await this.source.canHandle(input); } - public async handle(input: InteractionHandlerInput): Promise { + public async handle(input: JsonInteractionHandlerInput): Promise { try { return await this.source.handle(input); } catch (error: unknown) { if (RedirectHttpError.isInstance(error)) { - const body = JSON.stringify({ location: error.location }); - return new BasicRepresentation(body, input.operation.target, APPLICATION_JSON); + this.logger.debug(`Converting redirect error to location field in JSON body with location ${error.location}`); + const metadata = new RepresentationMetadata(input.target); + metadata.set(SOLID_HTTP.terms.location, error.location); + return { json: { location: error.location }, metadata }; } throw error; } diff --git a/src/identity/interaction/LockingInteractionHandler.ts b/src/identity/interaction/LockingInteractionHandler.ts new file mode 100644 index 000000000..9e85010a1 --- /dev/null +++ b/src/identity/interaction/LockingInteractionHandler.ts @@ -0,0 +1,44 @@ +import type { Representation } from '../../http/representation/Representation'; +import type { ReadWriteLocker } from '../../util/locking/ReadWriteLocker'; +import type { AccountIdRoute } from './account/AccountIdRoute'; +import type { InteractionHandlerInput } from './InteractionHandler'; +import { InteractionHandler } from './InteractionHandler'; + +const READ_METHODS = new Set([ 'OPTIONS', 'HEAD', 'GET' ]); + +/** + * An {@link InteractionHandler} that locks the path generated with the stored route during an operation. + * If the route is the base account route, this can be used to prevent multiple operations on the same account. + */ +export class LockingInteractionHandler extends InteractionHandler { + private readonly locker: ReadWriteLocker; + private readonly accountRoute: AccountIdRoute; + private readonly source: InteractionHandler; + + public constructor(locker: ReadWriteLocker, accountRoute: AccountIdRoute, source: InteractionHandler) { + super(); + this.locker = locker; + this.accountRoute = accountRoute; + this.source = source; + } + + public async canHandle(input: InteractionHandlerInput): Promise { + return this.source.canHandle(input); + } + + public async handle(input: InteractionHandlerInput): Promise { + const { accountId, operation } = input; + + // No lock if there is no account + if (!accountId) { + return this.source.handle(input); + } + + const identifier = { path: this.accountRoute.getPath({ accountId }) }; + if (READ_METHODS.has(operation.method)) { + return this.locker.withReadLock(identifier, (): Promise => this.source.handle(input)); + } + + return this.locker.withWriteLock(identifier, (): Promise => this.source.handle(input)); + } +} diff --git a/src/identity/interaction/OidcControlHandler.ts b/src/identity/interaction/OidcControlHandler.ts new file mode 100644 index 000000000..7da91bc8c --- /dev/null +++ b/src/identity/interaction/OidcControlHandler.ts @@ -0,0 +1,16 @@ +import { ControlHandler } from './ControlHandler'; +import type { Json } from './InteractionUtil'; +import type { JsonInteractionHandlerInput } from './JsonInteractionHandler'; + +/** + * A {@link ControlHandler} that only returns results if there is an active OIDC interaction. + */ +export class OidcControlHandler extends ControlHandler { + protected async generateControls(input: JsonInteractionHandlerInput): Promise> { + if (!input.oidcInteraction) { + return {}; + } + + return super.generateControls(input); + } +} diff --git a/src/identity/interaction/PromptHandler.ts b/src/identity/interaction/PromptHandler.ts deleted file mode 100644 index 1eb838204..000000000 --- a/src/identity/interaction/PromptHandler.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; -import { FoundHttpError } from '../../util/errors/FoundHttpError'; -import { InteractionHandler } from './InteractionHandler'; -import type { InteractionHandlerInput } from './InteractionHandler'; -import type { InteractionRoute } from './routing/InteractionRoute'; - -/** - * Redirects requests based on the OIDC Interaction prompt. - * Errors in case no match was found. - */ -export class PromptHandler extends InteractionHandler { - private readonly promptRoutes: Record; - - public constructor(promptRoutes: Record) { - super(); - this.promptRoutes = promptRoutes; - } - - public async handle({ oidcInteraction }: InteractionHandlerInput): Promise { - // We also want to redirect on GET so no method check is needed - const prompt = oidcInteraction?.prompt.name; - if (prompt && this.promptRoutes[prompt]) { - const location = this.promptRoutes[prompt].getPath(); - throw new FoundHttpError(location); - } - throw new BadRequestHttpError(`Unsupported prompt: ${prompt}`); - } -} diff --git a/src/identity/interaction/StaticInteractionHandler.ts b/src/identity/interaction/StaticInteractionHandler.ts new file mode 100644 index 000000000..686680bef --- /dev/null +++ b/src/identity/interaction/StaticInteractionHandler.ts @@ -0,0 +1,23 @@ +/* eslint-disable tsdoc/syntax */ +import type { Json, JsonRepresentation } from './InteractionUtil'; +// Tsdoc/syntax cannot handle `@range` +import { JsonInteractionHandler } from './JsonInteractionHandler'; + +/** + * An {@link JsonInteractionHandler} that always returns the same JSON response on all requests. + */ +export class StaticInteractionHandler extends JsonInteractionHandler { + private readonly response: Record; + + /** + * @param response - @range {json} + */ + public constructor(response: Record) { + super(); + this.response = response; + } + + public async handle(): Promise { + return { json: this.response }; + } +} diff --git a/src/identity/interaction/VersionHandler.ts b/src/identity/interaction/VersionHandler.ts new file mode 100644 index 000000000..4aaf9b479 --- /dev/null +++ b/src/identity/interaction/VersionHandler.ts @@ -0,0 +1,28 @@ +import type { JsonRepresentation } from './InteractionUtil'; +import type { JsonInteractionHandlerInput } from './JsonInteractionHandler'; +import { JsonInteractionHandler } from './JsonInteractionHandler'; + +const INTERNAL_API_VERSION = '0.5'; + +/** + * Adds the current version of the API to the JSON output. + * This version number should be updated every time the API changes. + */ +export class VersionHandler extends JsonInteractionHandler { + private readonly source: JsonInteractionHandler; + + public constructor(source: JsonInteractionHandler) { + super(); + this.source = source; + } + + public async canHandle(input: JsonInteractionHandlerInput): Promise { + await this.source.canHandle(input); + } + + public async handle(input: JsonInteractionHandlerInput): Promise { + const result = await this.source.handle(input); + result.json.version = INTERNAL_API_VERSION; + return result; + } +} diff --git a/src/identity/interaction/ViewInteractionHandler.ts b/src/identity/interaction/ViewInteractionHandler.ts new file mode 100644 index 000000000..23ac47576 --- /dev/null +++ b/src/identity/interaction/ViewInteractionHandler.ts @@ -0,0 +1,40 @@ +import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError'; +import type { JsonRepresentation } from './InteractionUtil'; +import type { JsonInteractionHandlerInput } from './JsonInteractionHandler'; +import { JsonInteractionHandler } from './JsonInteractionHandler'; +import type { JsonView } from './JsonView'; + +/** + * Utility class for the common case of a {@link JsonInteractionHandler} + * describing the expected input on a GET request which is needed to do a POST request. + * + * Returns the result of a {@link JsonView} on GET requests. + * POST requests are sent to the {@link JsonInteractionHandler}. + * Other methods will be rejected. + */ +export class ViewInteractionHandler extends JsonInteractionHandler { + private readonly source: JsonInteractionHandler & JsonView; + + public constructor(source: JsonInteractionHandler & JsonView) { + super(); + this.source = source; + } + + public async canHandle(input: JsonInteractionHandlerInput): Promise { + const { method } = input; + if (method !== 'GET' && method !== 'POST') { + throw new MethodNotAllowedHttpError([ method ], 'Only GET/POST requests are supported.'); + } + + if (method === 'POST') { + await this.source.canHandle(input); + } + } + + public async handle(input: JsonInteractionHandlerInput): Promise { + if (input.method === 'GET') { + return this.source.getView(input); + } + return this.source.handle(input); + } +} diff --git a/src/identity/interaction/YupUtil.ts b/src/identity/interaction/YupUtil.ts new file mode 100644 index 000000000..bf4059527 --- /dev/null +++ b/src/identity/interaction/YupUtil.ts @@ -0,0 +1,68 @@ +import { string } from 'yup'; +import type { ObjectSchema, Schema, ValidateOptions } from 'yup'; +import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError'; +import { createErrorMessage } from '../../util/errors/ErrorUtil'; +import { isUrl } from '../../util/StringUtil'; +import type { Json } from './InteractionUtil'; +import Dict = NodeJS.Dict; + +// The builtin `url` validator of `yup` does not support localhost URLs, so we create a custom one here. +// The reason for having a URL validator on the WebID is to prevent us from generating invalid ACL, +// which would break the pod creation causing us to have an incomplete pod. +export const URL_SCHEMA = string().trim().optional().test({ + name: 'url', + message: (value): string => `"${value.value}" is not a valid URL`, + test(value): boolean { + if (!value) { + return true; + } + return isUrl(value); + }, +}); + +function isObjectSchema(schema: Schema): schema is ObjectSchema { + return schema.type === 'object'; +} + +// `T` can't extend Schema since it could also be a Reference, which is a type `yup` doesn't export +type SchemaType = T extends ObjectSchema ? ObjectType : { required: boolean; type: string }; +// The type of the fields in an object schema +type FieldType> = T extends { fields: Record } ? R : never; +// Simplified type we use to represent yup objects +type ObjectType> = + { required: boolean; type: 'object'; fields: {[ K in FieldType ]: SchemaType }}; + +/** + * Recursive function used when generating yup schema representations. + */ +function parseSchemaDescription(schema: T): SchemaType { + const result: Dict = { required: !schema.spec.optional, type: schema.type }; + if (isObjectSchema(schema)) { + result.fields = {}; + for (const [ field, description ] of Object.entries(schema.fields)) { + // We never use references so this cast is fine + result.fields[field] = parseSchemaDescription(description as Schema); + } + } + return result as SchemaType; +} + +/** + * Generates a simplified representation of a yup schema. + */ +export function parseSchema>(schema: T): Pick, 'fields'> { + const result = parseSchemaDescription(schema); + return { fields: result.fields }; +} + +/** + * Same functionality as the yup validate function, but throws a {@link BadRequestHttpError} if there is an error. + */ +export async function validateWithError>(schema: T, data: unknown, + options?: ValidateOptions): Promise { + try { + return await schema.validate(data, options); + } catch (error: unknown) { + throw new BadRequestHttpError(createErrorMessage(error)); + } +} diff --git a/src/identity/interaction/account/AccountDetailsHandler.ts b/src/identity/interaction/account/AccountDetailsHandler.ts new file mode 100644 index 000000000..c60493441 --- /dev/null +++ b/src/identity/interaction/account/AccountDetailsHandler.ts @@ -0,0 +1,29 @@ +import type { Json, JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { Account } from './util/Account'; +import type { AccountStore } from './util/AccountStore'; +import { getRequiredAccount } from './util/AccountUtil'; +import Dict = NodeJS.Dict; + +/** + * Outputs a JSON description of the account details. + */ +export class AccountDetailsHandler extends JsonInteractionHandler { + private readonly accountStore: AccountStore; + + public constructor(accountStore: AccountStore) { + super(); + this.accountStore = accountStore; + } + + public async handle({ accountId }: JsonInteractionHandlerInput): Promise> { + const account = await getRequiredAccount(this.accountStore, accountId); + + // The ID does not need to be in the JSON + const json: Dict = account; + delete json.id; + + return { json: account }; + } +} diff --git a/src/identity/interaction/account/AccountIdRoute.ts b/src/identity/interaction/account/AccountIdRoute.ts new file mode 100644 index 000000000..d47e441c3 --- /dev/null +++ b/src/identity/interaction/account/AccountIdRoute.ts @@ -0,0 +1,21 @@ +import { IdInteractionRoute } from '../routing/IdInteractionRoute'; +import type { InteractionRoute } from '../routing/InteractionRoute'; + +// AccountIdKey = typeof ACCOUNT_ID_KEY does not work because Components.js doesn't support typeof like that + +export type AccountIdKey = 'accountId'; +export const ACCOUNT_ID_KEY: AccountIdKey = 'accountId'; + +/** + * A route that includes an account identifier. + */ +export type AccountIdRoute = InteractionRoute; + +/** + * Implementation of an {@link AccountIdRoute} that adds the identifier relative to a base {@link InteractionRoute}. + */ +export class BaseAccountIdRoute extends IdInteractionRoute implements AccountIdRoute { + public constructor(base: InteractionRoute) { + super(base, 'accountId'); + } +} diff --git a/src/identity/interaction/account/CreateAccountHandler.ts b/src/identity/interaction/account/CreateAccountHandler.ts new file mode 100644 index 000000000..e42ca9e9b --- /dev/null +++ b/src/identity/interaction/account/CreateAccountHandler.ts @@ -0,0 +1,27 @@ +import type { EmptyObject } from '../../../util/map/MapUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonView } from '../JsonView'; +import type { LoginOutputType } from '../login/ResolveLoginHandler'; +import { ResolveLoginHandler } from '../login/ResolveLoginHandler'; +import type { AccountIdRoute } from './AccountIdRoute'; +import type { AccountStore } from './util/AccountStore'; +import type { CookieStore } from './util/CookieStore'; + +/** + * Creates new accounts using an {@link AccountStore}; + */ +export class CreateAccountHandler extends ResolveLoginHandler implements JsonView { + public constructor(accountStore: AccountStore, cookieStore: CookieStore, accountRoute: AccountIdRoute) { + super(accountStore, cookieStore, accountRoute); + } + + public async getView(): Promise> { + return { json: {}}; + } + + public async login(): Promise> { + const account = await this.accountStore.create(); + + return { json: { accountId: account.id }}; + } +} diff --git a/src/identity/interaction/account/util/Account.ts b/src/identity/interaction/account/util/Account.ts new file mode 100644 index 000000000..c001c17ae --- /dev/null +++ b/src/identity/interaction/account/util/Account.ts @@ -0,0 +1,57 @@ +import type { Json } from '../../InteractionUtil'; +import Dict = NodeJS.Dict; + +/** + * Settings parameter used to determine if the user wants the login to be remembered. + */ +export const ACCOUNT_SETTINGS_REMEMBER_LOGIN = 'rememberLogin'; + +/** + * Object used to keep track of all the relevant account data. + * All key/value objects stored in this are expected to have the same similar structure: + * the keys should be the unique value relevant for that type of data, + * while the values should be the URL of the corresponding resource that can be used to potentially modify this entry. + */ +export type Account = { + /** + * A unique identifier for this account. + */ + readonly id: string; + /** + * All login methods that can be used to identify as this account. + * As one login method can have multiple entries, this is a nested map. + * You could have several different e-mail addresses to log in with for example. + * The keys of the first map are the unique identifiers of the login methods. + * The keys of the second map are the unique identifiers of the entry within that login method. + * + * For example, assume we have a login method `password` that uses e-mail addresses to identify entries, + * this could look as follows: + * `{ logins: { password: { ['test@example.com']: 'http://localhost:3000/.account/123/logins/password/123' } } }`. + * + * Implementations should make sure it is not possible to have an account without login method, + * as that would make the account inaccessible. + */ + readonly logins: Dict>; + /** + * The pods this account is the owner of. + * The keys are the base URLs of those pods. + */ + readonly pods: Dict; + /** + * All WebIDs registered to this account, + * meaning this account can identify as any of these WebIDs after logging in. + * The keys are the actual WebIDs. + */ + readonly webIds: Dict; + /** + * The client credentials stored for this account. + * The keys are the IDs of the tokens. + */ + readonly clientCredentials: Dict; + /** + * Various settings of the account. + * This is an open-ended object that can be used for any settings that need to be tracked on an account, + * hence there are no strict typings on the values. + */ + readonly settings: Dict; +}; diff --git a/src/identity/interaction/account/util/AccountStore.ts b/src/identity/interaction/account/util/AccountStore.ts new file mode 100644 index 000000000..16e20e20b --- /dev/null +++ b/src/identity/interaction/account/util/AccountStore.ts @@ -0,0 +1,28 @@ +import type { Account } from './Account'; + +/** + * Used to store account data. + */ +export interface AccountStore { + /** + * Creates a new and completely empty account. + * Since this account will not yet have a login method, + * implementations should restrict what is possible with this account, + * and should potentially have something in place to clean these accounts up if they are unused. + */ + create: () => Promise; + /** + * Finds the account with the given identifier. + * @param id - The account identifier. + */ + get: (id: string) => Promise; + /** + * Updates the account with the given values. + * The account will be completely overwritten with the provided {@link Account} object. + * + * It should not be possible to update an account to have no login methods. + * + * @param account - The new values for the account. + */ + update: (account: Account) => Promise; +} diff --git a/src/identity/interaction/account/util/AccountUtil.ts b/src/identity/interaction/account/util/AccountUtil.ts new file mode 100644 index 000000000..c14448431 --- /dev/null +++ b/src/identity/interaction/account/util/AccountUtil.ts @@ -0,0 +1,93 @@ +import { getLoggerFor } from '../../../../logging/LogUtil'; +import { NotFoundHttpError } from '../../../../util/errors/NotFoundHttpError'; +import type { Account } from './Account'; +import type { AccountStore } from './AccountStore'; +import Dict = NodeJS.Dict; + +const logger = getLoggerFor('AccountUtil'); + +/** + * Finds the account in the store for the given `accountId`. + * Throws a {@link NotFoundHttpError} if no account is found. + * + * @param accountStore - Account store to look in. + * @param accountId - Identifier to look up. + */ +export async function getRequiredAccount(accountStore: AccountStore, accountId?: string): Promise { + const account = accountId && await accountStore.get(accountId); + if (!account) { + logger.debug('Missing account'); + throw new NotFoundHttpError(); + } + return account; +} + +/** + * Looks for the key in the provided `data` object with `resource` as value. + * This was designed specifically for working with {@link Account} data where you have a resource + * but don't know which key it is associated with. + * + * @param data - Object to look in. + * @param resource - The resource URL. + * + * @throws A {@link NotFoundHttpError} if no match could be found. + */ +export function ensureResource(data?: Dict, resource?: string): string { + if (!data || !resource) { + throw new NotFoundHttpError(); + } + const token = Object.keys(data).find((key): boolean => data[key] === resource); + if (!token) { + logger.debug(`Missing resource ${resource}`); + throw new NotFoundHttpError(); + } + return token; +} + +/** + * Adds a login entry for a specific login method to the account data. + * + * @param account - {@link Account} to update. + * @param method - Name of the login method. + * @param key - Key of the entry. + * @param resource - Resource associated with the entry. + */ +export function addLoginEntry(account: Account, method: string, key: string, resource: string): void { + const logins = account.logins[method] ?? {}; + account.logins[method] = logins; + logins[key] = resource; +} + +/** + * Updates {@link Account} data in such a way to minimize chances of incomplete updates + * when multiple storages have to be updated simultaneously. + * + * First the `accountStore` will be used to update the account, then the `operation` function will be executed. + * If that latter call fails, the updates done to the account will be reverted. + * In both success and failure, the result of calling `operation` will be returned. + * + * @param account - The account object with the new data. If the `operation` call fails, + * this object will be updated to contain the original account data. + * @param accountStore - Store used to update the account. + * @param operation - Function to execute safely. + */ +export async function safeUpdate(account: Account, accountStore: AccountStore, operation: () => Promise): +Promise { + const oldAccount = await accountStore.get(account.id); + if (!oldAccount) { + throw new NotFoundHttpError(); + } + + await accountStore.update(account); + try { + return await operation(); + } catch (error: unknown) { + logger.warn(`Error while updating account ${account.id}, reverting operation.`); + await accountStore.update(oldAccount); + // Update the keys of the input `account` variable to make sure it matches what is now stored again. + // This is relevant if the error thrown here is caught and the account object is still used for some reason. + Object.assign(account, oldAccount); + + throw error; + } +} diff --git a/src/identity/interaction/account/util/BaseAccountStore.ts b/src/identity/interaction/account/util/BaseAccountStore.ts new file mode 100644 index 000000000..ad6d6add8 --- /dev/null +++ b/src/identity/interaction/account/util/BaseAccountStore.ts @@ -0,0 +1,69 @@ +import { v4 } from 'uuid'; +import { getLoggerFor } from '../../../../logging/LogUtil'; +import type { ExpiringStorage } from '../../../../storage/keyvalue/ExpiringStorage'; +import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; +import { NotFoundHttpError } from '../../../../util/errors/NotFoundHttpError'; +import type { Account } from './Account'; +import type { AccountStore } from './AccountStore'; + +/** + * A {@link AccountStore} that uses an {@link ExpiringStorage} to keep track of the accounts. + * Created accounts will be removed after the chosen expiration in seconds, default 30 minutes, + * if no login method gets added. + * + * New accounts can not be updated unless the update includes at least 1 login method. + */ +export class BaseAccountStore implements AccountStore { + private readonly logger = getLoggerFor(this); + + private readonly storage: ExpiringStorage; + private readonly expiration: number; + + public constructor(storage: ExpiringStorage, expiration = 30 * 60) { + this.storage = storage; + this.expiration = expiration * 1000; + } + + public async create(): Promise { + const id = v4(); + const account: Account = { + id, + logins: {}, + pods: {}, + webIds: {}, + clientCredentials: {}, + settings: {}, + }; + + // Expire accounts after some time if no login gets added + await this.storage.set(id, account, this.expiration); + this.logger.debug(`Created new account ${id}`); + + return account; + } + + public async get(id: string): Promise { + return this.storage.get(id); + } + + public async update(account: Account): Promise { + const oldAccount = await this.get(account.id); + // Make sure the account exists + if (!oldAccount) { + this.logger.warn(`Trying to update account ${account.id} which does not exist`); + throw new NotFoundHttpError(); + } + + // Ensure there is at least 1 login method + const logins = Object.values(account.logins); + if (!logins.some((specificLogins): boolean => Object.keys(specificLogins ?? {}).length > 0)) { + this.logger.warn(`Trying to update account ${account.id} without login methods`); + throw new BadRequestHttpError('An account needs at least 1 login method.'); + } + + // This will disable the expiration if there still was one + await this.storage.set(account.id, account); + + this.logger.debug(`Updated account ${account.id}`); + } +} diff --git a/src/identity/interaction/account/util/BaseCookieStore.ts b/src/identity/interaction/account/util/BaseCookieStore.ts new file mode 100644 index 000000000..633b22f0f --- /dev/null +++ b/src/identity/interaction/account/util/BaseCookieStore.ts @@ -0,0 +1,40 @@ +import { v4 } from 'uuid'; +import type { ExpiringStorage } from '../../../../storage/keyvalue/ExpiringStorage'; +import type { CookieStore } from './CookieStore'; + +/** + * A {@link CookieStore} that uses an {@link ExpiringStorage} to keep track of the stored cookies. + * Cookies have a specified time to live in seconds, default is 14 days, + * after which they will be removed. + */ +export class BaseCookieStore implements CookieStore { + private readonly storage: ExpiringStorage; + private readonly ttl: number; + + public constructor(storage: ExpiringStorage, ttl = 14 * 24 * 60 * 60) { + this.storage = storage; + this.ttl = ttl * 1000; + } + + public async generate(accountId: string): Promise { + const cookie = v4(); + await this.storage.set(cookie, accountId, this.ttl); + return cookie; + } + + public async get(cookie: string): Promise { + return await this.storage.get(cookie); + } + + public async refresh(cookie: string): Promise { + const accountId = await this.storage.get(cookie); + if (accountId) { + await this.storage.set(cookie, accountId, this.ttl); + return new Date(Date.now() + this.ttl); + } + } + + public async delete(cookie: string): Promise { + return this.storage.delete(cookie); + } +} diff --git a/src/identity/interaction/account/util/CookieStore.ts b/src/identity/interaction/account/util/CookieStore.ts new file mode 100644 index 000000000..d6ea3c9e8 --- /dev/null +++ b/src/identity/interaction/account/util/CookieStore.ts @@ -0,0 +1,31 @@ +/** + * Used to generate and store cookies. + */ +export interface CookieStore { + /** + * Generates and stores a new cookie for the given accountId. + * This does not replace previously generated cookies. + * @param accountId - Account to create a cookie for. + * + * @returns The generated cookie. + */ + generate: (accountId: string) => Promise; + + /** + * Return the accountID associated with the given cookie. + * @param cookie - Cookie to find the account for. + */ + get: (cookie: string) => Promise; + + /** + * Refreshes the cookie expiration and returns when it will expire if the cookie exists. + * @param cookie - Cookie to refresh. + */ + refresh: (cookie: string) => Promise; + + /** + * Deletes the given cookie. + * @param cookie - Cookie to delete. + */ + delete: (cookie: string) => Promise; +} diff --git a/src/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.ts b/src/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.ts new file mode 100644 index 000000000..af06862e8 --- /dev/null +++ b/src/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.ts @@ -0,0 +1,80 @@ +import type { Adapter, AdapterPayload } from '../../../../templates/types/oidc-provider'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import type { AdapterFactory } from '../../storage/AdapterFactory'; +import { PassthroughAdapterFactory, PassthroughAdapter } from '../../storage/PassthroughAdapterFactory'; +import type { AccountStore } from '../account/util/AccountStore'; +import type { ClientCredentialsStore } from './util/ClientCredentialsStore'; + +/** + * A {@link PassthroughAdapter} that overrides the `find` function + * by checking if there are stored client credentials for the given ID + * if no payload is found in the source. + */ +export class ClientCredentialsAdapter extends PassthroughAdapter { + protected readonly logger = getLoggerFor(this); + + private readonly accountStore: AccountStore; + private readonly clientCredentialsStore: ClientCredentialsStore; + + public constructor(name: string, source: Adapter, accountStore: AccountStore, + clientCredentialsStore: ClientCredentialsStore) { + super(name, source); + this.accountStore = accountStore; + this.clientCredentialsStore = clientCredentialsStore; + } + + public async find(id: string): Promise { + let payload = await this.source.find(id); + + if (!payload && this.name === 'Client') { + const credentials = await this.clientCredentialsStore.get(id); + if (credentials) { + // Make sure the WebID is still linked to the account. + // Unlinking a WebID does not necessarily delete the corresponding credential tokens. + const account = await this.accountStore.get(credentials.accountId); + if (!account) { + this.logger.error(`Storage contains credentials ${id} with unknown account ID ${credentials.accountId}`); + return; + } + + if (!account.webIds[credentials.webId]) { + this.logger.warn( + `Client credentials token ${id} contains WebID that is no longer linked to the account. Removing...`, + ); + await this.clientCredentialsStore.delete(id, account); + return; + } + + this.logger.debug(`Authenticating as ${credentials.webId} using client credentials`); + + /* eslint-disable @typescript-eslint/naming-convention */ + payload = { + client_id: id, + client_secret: credentials.secret, + grant_types: [ 'client_credentials' ], + redirect_uris: [], + response_types: [], + }; + /* eslint-enable @typescript-eslint/naming-convention */ + } + } + return payload; + } +} + +export class ClientCredentialsAdapterFactory extends PassthroughAdapterFactory { + private readonly accountStore: AccountStore; + private readonly clientCredentialsStore: ClientCredentialsStore; + + public constructor(source: AdapterFactory, accountStore: AccountStore, + clientCredentialsStore: ClientCredentialsStore) { + super(source); + this.accountStore = accountStore; + this.clientCredentialsStore = clientCredentialsStore; + } + + public createStorageAdapter(name: string): Adapter { + const adapter = this.source.createStorageAdapter(name); + return new ClientCredentialsAdapter(name, adapter, this.accountStore, this.clientCredentialsStore); + } +} diff --git a/src/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.ts b/src/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.ts new file mode 100644 index 000000000..e790f883d --- /dev/null +++ b/src/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.ts @@ -0,0 +1,48 @@ +import { getLoggerFor } from '../../../logging/LogUtil'; +import { InternalServerError } from '../../../util/errors/InternalServerError'; +import type { AccountStore } from '../account/util/AccountStore'; +import { ensureResource, getRequiredAccount } from '../account/util/AccountUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { ClientCredentialsStore } from './util/ClientCredentialsStore'; + +type OutType = { + id: string; + webId: string; +}; + +/** + * Provides a view on a client credentials token, indicating the token identifier and its associated WebID. + */ +export class ClientCredentialsDetailsHandler extends JsonInteractionHandler { + protected readonly logger = getLoggerFor(this); + + private readonly accountStore: AccountStore; + private readonly clientCredentialsStore: ClientCredentialsStore; + + public constructor(accountStore: AccountStore, clientCredentialsStore: ClientCredentialsStore) { + super(); + this.accountStore = accountStore; + this.clientCredentialsStore = clientCredentialsStore; + } + + public async handle({ target, accountId }: JsonInteractionHandlerInput): Promise> { + const account = await getRequiredAccount(this.accountStore, accountId); + + const id = ensureResource(account.clientCredentials, target.path); + + const credentials = await this.clientCredentialsStore.get(id); + if (!credentials) { + this.logger.error( + `Data inconsistency between account and credentials data for account ${account.id} and token ${id}.`, + ); + throw new InternalServerError('Data inconsistency between account and client credentials data.'); + } + + return { json: { + id, + webId: credentials.webId, + }}; + } +} diff --git a/src/identity/interaction/client-credentials/CreateClientCredentialsHandler.ts b/src/identity/interaction/client-credentials/CreateClientCredentialsHandler.ts new file mode 100644 index 000000000..88ca2ec9a --- /dev/null +++ b/src/identity/interaction/client-credentials/CreateClientCredentialsHandler.ts @@ -0,0 +1,52 @@ +import { v4 } from 'uuid'; +import { object, string } from 'yup'; +import { sanitizeUrlPart } from '../../../util/StringUtil'; +import type { AccountStore } from '../account/util/AccountStore'; +import { getRequiredAccount } from '../account/util/AccountUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import type { JsonView } from '../JsonView'; +import { parseSchema, validateWithError } from '../YupUtil'; +import type { ClientCredentialsStore } from './util/ClientCredentialsStore'; + +const inSchema = object({ + name: string().trim().optional(), + webId: string().trim().required(), +}); + +type OutType = { + id: string; + secret: string; + resource: string; +}; + +/** + * Handles the creation of client credential tokens. + */ +export class CreateClientCredentialsHandler extends JsonInteractionHandler implements JsonView { + private readonly accountStore: AccountStore; + private readonly clientCredentialsStore: ClientCredentialsStore; + + public constructor(accountStore: AccountStore, clientCredentialsStore: ClientCredentialsStore) { + super(); + this.accountStore = accountStore; + this.clientCredentialsStore = clientCredentialsStore; + } + + public async getView(): Promise { + return { json: parseSchema(inSchema) }; + } + + public async handle({ accountId, json }: JsonInteractionHandlerInput): Promise> { + const account = await getRequiredAccount(this.accountStore, accountId); + + const { name, webId } = await validateWithError(inSchema, json); + const cleanedName = name ? sanitizeUrlPart(name.trim()) : ''; + const id = `${cleanedName}_${v4()}`; + + const { secret, resource } = await this.clientCredentialsStore.add(id, webId, account); + + return { json: { id, secret, resource }}; + } +} diff --git a/src/identity/interaction/client-credentials/DeleteClientCredentialsHandler.ts b/src/identity/interaction/client-credentials/DeleteClientCredentialsHandler.ts new file mode 100644 index 000000000..d001d2cc4 --- /dev/null +++ b/src/identity/interaction/client-credentials/DeleteClientCredentialsHandler.ts @@ -0,0 +1,32 @@ +import type { EmptyObject } from '../../../util/map/MapUtil'; +import type { AccountStore } from '../account/util/AccountStore'; +import { ensureResource, getRequiredAccount } from '../account/util/AccountUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { ClientCredentialsStore } from './util/ClientCredentialsStore'; + +/** + * Handles the deletion of client credentials tokens. + */ +export class DeleteClientCredentialsHandler extends JsonInteractionHandler { + private readonly accountStore: AccountStore; + private readonly clientCredentialsStore: ClientCredentialsStore; + + public constructor(accountStore: AccountStore, clientCredentialsStore: ClientCredentialsStore) { + super(); + this.accountStore = accountStore; + this.clientCredentialsStore = clientCredentialsStore; + } + + public async handle({ target, accountId }: JsonInteractionHandlerInput): Promise> { + const account = await getRequiredAccount(this.accountStore, accountId); + + const id = ensureResource(account.clientCredentials, target.path); + + // This also deletes it from the account + await this.clientCredentialsStore.delete(id, account); + + return { json: {}}; + } +} diff --git a/src/identity/interaction/client-credentials/util/BaseClientCredentialsStore.ts b/src/identity/interaction/client-credentials/util/BaseClientCredentialsStore.ts new file mode 100644 index 000000000..120708684 --- /dev/null +++ b/src/identity/interaction/client-credentials/util/BaseClientCredentialsStore.ts @@ -0,0 +1,63 @@ +import { randomBytes } from 'crypto'; +import { getLoggerFor } from '../../../../logging/LogUtil'; +import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; +import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; +import type { Account } from '../../account/util/Account'; +import type { AccountStore } from '../../account/util/AccountStore'; +import { safeUpdate } from '../../account/util/AccountUtil'; +import type { ClientCredentialsIdRoute } from './ClientCredentialsIdRoute'; +import type { ClientCredentials, ClientCredentialsStore } from './ClientCredentialsStore'; + +/** + * A {@link ClientCredentialsStore} that uses a {@link KeyValueStorage} for storing the tokens. + */ +export class BaseClientCredentialsStore implements ClientCredentialsStore { + private readonly logger = getLoggerFor(this); + + private readonly clientCredentialsRoute: ClientCredentialsIdRoute; + private readonly accountStore: AccountStore; + private readonly storage: KeyValueStorage; + + public constructor(clientCredentialsRoute: ClientCredentialsIdRoute, accountStore: AccountStore, + storage: KeyValueStorage) { + this.clientCredentialsRoute = clientCredentialsRoute; + this.accountStore = accountStore; + this.storage = storage; + } + + public async get(id: string): Promise { + return this.storage.get(id); + } + + public async add(id: string, webId: string, account: Account): Promise<{ secret: string; resource: string }> { + if (typeof account.webIds[webId] !== 'string') { + this.logger.warn(`Trying to create token for ${webId} which does not belong to account ${account.id}`); + throw new BadRequestHttpError('WebID does not belong to this account.'); + } + + const secret = randomBytes(64).toString('hex'); + const resource = this.clientCredentialsRoute.getPath({ accountId: account.id, clientCredentialsId: id }); + + account.clientCredentials[id] = resource; + await safeUpdate(account, + this.accountStore, + (): Promise => this.storage.set(id, { accountId: account.id, secret, webId })); + + this.logger.debug(`Created client credentials token ${id} for WebID ${webId} and account ${account.id}`); + + return { secret, resource }; + } + + public async delete(id: string, account: Account): Promise { + const link = account.clientCredentials[id]; + + if (link) { + delete account.clientCredentials[id]; + await safeUpdate(account, + this.accountStore, + (): Promise => this.storage.delete(id)); + + this.logger.debug(`Deleted client credentials token ${id} for account ${account.id}`); + } + } +} diff --git a/src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute.ts b/src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute.ts new file mode 100644 index 000000000..a9044e03f --- /dev/null +++ b/src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute.ts @@ -0,0 +1,20 @@ +import type { AccountIdKey, AccountIdRoute } from '../../account/AccountIdRoute'; +import { IdInteractionRoute } from '../../routing/IdInteractionRoute'; +import type { ExtendedRoute } from '../../routing/InteractionRoute'; + +export type CredentialsIdKey = 'clientCredentialsId'; + +/** + * An {@link AccountIdRoute} that also includes a credentials identifier. + */ +export type ClientCredentialsIdRoute = ExtendedRoute; + +/** + * Implementation of an {@link ClientCredentialsIdRoute} + * that adds the identifier relative to a base {@link AccountIdRoute}. + */ +export class BaseClientCredentialsIdRoute extends IdInteractionRoute { + public constructor(base: AccountIdRoute) { + super(base, 'clientCredentialsId'); + } +} diff --git a/src/identity/interaction/client-credentials/util/ClientCredentialsStore.ts b/src/identity/interaction/client-credentials/util/ClientCredentialsStore.ts new file mode 100644 index 000000000..2088d6b97 --- /dev/null +++ b/src/identity/interaction/client-credentials/util/ClientCredentialsStore.ts @@ -0,0 +1,47 @@ +import type { Account } from '../../account/util/Account'; + +/** + * A client credentials token. + * If at some point the WebID is no longer registered to the account stored in this token, + * the token should be invalidated. + */ +export interface ClientCredentials { + /** + * The identifier of the account that created the token. + */ + accountId: string; + /** + * The secret of the token. + */ + secret: string; + /** + * The WebID users will be identified as after using the token. + */ + webId: string; +} + +/** + * Stores and creates {@link ClientCredentials}. + */ +export interface ClientCredentialsStore { + /** + * Find the {@link ClientCredentials} with the given label. Undefined if there is no match. + * @param label - Label of the credentials. + */ + get: (label: string) => Promise; + /** + * Creates new {@link ClientCredentials} and adds a reference to the account. + * Will error if the WebID is not registered to the account. + * + * @param label - Identifier to use for the new credentials. + * @param webId - WebID to identify as when using this token. + * @param account - Account that is associated with this token. + */ + add: (label: string, webId: string, account: Account) => Promise<{ secret: string; resource: string }>; + /** + * Deletes the token with the given identifier and removes the reference from the account. + * @param label - Identifier of the token. + * @param account - Account this token belongs to. + */ + delete: (label: string, account: Account) => Promise; +} diff --git a/src/identity/interaction/email-password/EmailPasswordUtil.ts b/src/identity/interaction/email-password/EmailPasswordUtil.ts deleted file mode 100644 index 8c6954af4..000000000 --- a/src/identity/interaction/email-password/EmailPasswordUtil.ts +++ /dev/null @@ -1,22 +0,0 @@ -import assert from 'assert'; - -/** - * Asserts that `password` is a string that matches `confirmPassword`. - * Will throw an Error otherwise. - * @param password - Password to assert. - * @param confirmPassword - Confirmation of password to match. - */ -export function assertPassword(password: any, confirmPassword: any): asserts password is string { - assert( - typeof password === 'string' && password.length > 0, - 'Please enter a password.', - ); - assert( - typeof confirmPassword === 'string' && confirmPassword.length > 0, - 'Please confirm your password.', - ); - assert( - password === confirmPassword, - 'Your password and confirmation did not match.', - ); -} diff --git a/src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.ts b/src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.ts deleted file mode 100644 index 4ed6d77d2..000000000 --- a/src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { AdapterPayload, Adapter } from '../../../../../templates/types/oidc-provider'; -import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; -import type { AdapterFactory } from '../../../storage/AdapterFactory'; -import { PassthroughAdapterFactory, PassthroughAdapter } from '../../../storage/PassthroughAdapterFactory'; - -export interface ClientCredentials { - secret: string; - webId: string; -} - -/** - * A {@link PassthroughAdapter} that overrides the `find` function - * by checking if there are stored client credentials for the given ID - * if no payload is found in the source. - */ -export class ClientCredentialsAdapter extends PassthroughAdapter { - private readonly storage: KeyValueStorage; - - public constructor(name: string, source: Adapter, storage: KeyValueStorage) { - super(name, source); - this.storage = storage; - } - - public async find(id: string): Promise { - let payload = await this.source.find(id); - - if (!payload && this.name === 'Client') { - const credentials = await this.storage.get(id); - if (credentials) { - /* eslint-disable @typescript-eslint/naming-convention */ - payload = { - client_id: id, - client_secret: credentials.secret, - grant_types: [ 'client_credentials' ], - redirect_uris: [], - response_types: [], - }; - /* eslint-enable @typescript-eslint/naming-convention */ - } - } - return payload; - } -} - -export class ClientCredentialsAdapterFactory extends PassthroughAdapterFactory { - private readonly storage: KeyValueStorage; - - public constructor(source: AdapterFactory, storage: KeyValueStorage) { - super(source); - this.storage = storage; - } - - public createStorageAdapter(name: string): Adapter { - return new ClientCredentialsAdapter(name, this.source.createStorageAdapter(name), this.storage); - } -} diff --git a/src/identity/interaction/email-password/credentials/CreateCredentialsHandler.ts b/src/identity/interaction/email-password/credentials/CreateCredentialsHandler.ts deleted file mode 100644 index 2cfe6615e..000000000 --- a/src/identity/interaction/email-password/credentials/CreateCredentialsHandler.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { randomBytes } from 'crypto'; -import { v4 } from 'uuid'; -import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; -import type { Representation } from '../../../../http/representation/Representation'; -import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; -import { APPLICATION_JSON } from '../../../../util/ContentTypes'; -import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; -import { NotImplementedHttpError } from '../../../../util/errors/NotImplementedHttpError'; -import type { AccountStore } from '../storage/AccountStore'; -import type { ClientCredentials } from './ClientCredentialsAdapterFactory'; -import type { CredentialsHandlerInput } from './CredentialsHandler'; -import { CredentialsHandler } from './CredentialsHandler'; - -/** - * Handles the creation of credential tokens. - * Requires a `name` field in the input JSON body, - * that will be used to generate the ID token. - */ -export class CreateCredentialsHandler extends CredentialsHandler { - private readonly accountStore: AccountStore; - private readonly credentialStorage: KeyValueStorage; - - public constructor(accountStore: AccountStore, credentialStorage: KeyValueStorage) { - super(); - this.accountStore = accountStore; - this.credentialStorage = credentialStorage; - } - - public async canHandle({ body }: CredentialsHandlerInput): Promise { - if (typeof body.name !== 'string') { - throw new NotImplementedHttpError(); - } - } - - public async handle({ operation, body: { webId, name }}: CredentialsHandlerInput): Promise { - const settings = await this.accountStore.getSettings(webId); - - if (!settings.useIdp) { - throw new BadRequestHttpError('This server is not an identity provider for this account.'); - } - - const id = `${(name as string).replace(/\W/gu, '-')}_${v4()}`; - const secret = randomBytes(64).toString('hex'); - - // Store the credentials, and point to them from the account - settings.clientCredentials = settings.clientCredentials ?? []; - settings.clientCredentials.push(id); - await this.accountStore.updateSettings(webId, settings); - await this.credentialStorage.set(id, { secret, webId }); - - const response = { id, secret }; - return new BasicRepresentation(JSON.stringify(response), operation.target, APPLICATION_JSON); - } -} diff --git a/src/identity/interaction/email-password/credentials/CredentialsHandler.ts b/src/identity/interaction/email-password/credentials/CredentialsHandler.ts deleted file mode 100644 index 44e605efc..000000000 --- a/src/identity/interaction/email-password/credentials/CredentialsHandler.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Operation } from '../../../../http/Operation'; -import type { Representation } from '../../../../http/representation/Representation'; -import { AsyncHandler } from '../../../../util/handlers/AsyncHandler'; - -export interface CredentialsHandlerBody extends Record { - email: string; - webId: string; -} - -/** - * `body` is the parsed JSON from `operation.body.data` with the WebID of the account having been added. - * This means that the data stream in the Operation can not be read again. - */ -export interface CredentialsHandlerInput { - operation: Operation; - body: CredentialsHandlerBody; -} - -/** - * Handles a request after the user has been authenticated - * by providing a valid email/password combination in the JSON body. - */ -export abstract class CredentialsHandler extends AsyncHandler { } diff --git a/src/identity/interaction/email-password/credentials/DeleteCredentialsHandler.ts b/src/identity/interaction/email-password/credentials/DeleteCredentialsHandler.ts deleted file mode 100644 index 2dd94a306..000000000 --- a/src/identity/interaction/email-password/credentials/DeleteCredentialsHandler.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; -import type { Representation } from '../../../../http/representation/Representation'; -import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; -import { APPLICATION_JSON } from '../../../../util/ContentTypes'; -import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; -import { NotImplementedHttpError } from '../../../../util/errors/NotImplementedHttpError'; -import type { AccountStore } from '../storage/AccountStore'; -import type { ClientCredentials } from './ClientCredentialsAdapterFactory'; -import type { CredentialsHandlerInput } from './CredentialsHandler'; -import { CredentialsHandler } from './CredentialsHandler'; - -/** - * Handles the deletion of credential tokens. - * Expects the JSON body to have a `delete` field with as value the ID of the token to be deleted. - * This should be replaced to be an actual DELETE request once the API supports it. - */ -export class DeleteCredentialsHandler extends CredentialsHandler { - private readonly accountStore: AccountStore; - private readonly credentialStorage: KeyValueStorage; - - public constructor(accountStore: AccountStore, credentialStorage: KeyValueStorage) { - super(); - this.accountStore = accountStore; - this.credentialStorage = credentialStorage; - } - - public async canHandle({ body }: CredentialsHandlerInput): Promise { - if (typeof body.delete !== 'string') { - throw new NotImplementedHttpError(); - } - } - - public async handle({ operation, body }: CredentialsHandlerInput): Promise { - const id = body.delete as string; - const settings = await this.accountStore.getSettings(body.webId); - settings.clientCredentials = settings.clientCredentials ?? []; - const idx = settings.clientCredentials.indexOf(id); - if (idx < 0) { - throw new BadRequestHttpError('No credential with this ID exists for this account.'); - } - - await this.credentialStorage.delete(id); - settings.clientCredentials.splice(idx, 1); - await this.accountStore.updateSettings(body.webId, settings); - return new BasicRepresentation(JSON.stringify({}), operation.target, APPLICATION_JSON); - } -} diff --git a/src/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.ts b/src/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.ts deleted file mode 100644 index 255a68628..000000000 --- a/src/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.ts +++ /dev/null @@ -1,37 +0,0 @@ -import assert from 'assert'; -import type { Representation } from '../../../../http/representation/Representation'; -import { MethodNotAllowedHttpError } from '../../../../util/errors/MethodNotAllowedHttpError'; -import { readJsonStream } from '../../../../util/StreamUtil'; -import type { InteractionHandlerInput } from '../../InteractionHandler'; -import { InteractionHandler } from '../../InteractionHandler'; -import type { AccountStore } from '../storage/AccountStore'; -import type { CredentialsHandler } from './CredentialsHandler'; - -/** - * Authenticates a user by the email/password in a JSON POST body. - * Passes the body and the WebID associated with that account to the source handler. - */ -export class EmailPasswordAuthorizer extends InteractionHandler { - private readonly accountStore: AccountStore; - private readonly source: CredentialsHandler; - - public constructor(accountStore: AccountStore, source: CredentialsHandler) { - super(); - this.accountStore = accountStore; - this.source = source; - } - - public async handle({ operation }: InteractionHandlerInput): Promise { - if (operation.method !== 'POST') { - throw new MethodNotAllowedHttpError([ operation.method ], 'Only POST requests are supported.'); - } - const json = await readJsonStream(operation.body.data); - const { email, password } = json; - assert(typeof email === 'string' && email.length > 0, 'Email required'); - assert(typeof password === 'string' && password.length > 0, 'Password required'); - const webId = await this.accountStore.authenticate(email, password); - // Password no longer needed from this point on - delete json.password; - return this.source.handleSafe({ operation, body: { ...json, email, webId }}); - } -} diff --git a/src/identity/interaction/email-password/credentials/ListCredentialsHandler.ts b/src/identity/interaction/email-password/credentials/ListCredentialsHandler.ts deleted file mode 100644 index 275ebc8e4..000000000 --- a/src/identity/interaction/email-password/credentials/ListCredentialsHandler.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; -import type { Representation } from '../../../../http/representation/Representation'; -import { APPLICATION_JSON } from '../../../../util/ContentTypes'; -import type { AccountStore } from '../storage/AccountStore'; -import type { CredentialsHandlerInput } from './CredentialsHandler'; -import { CredentialsHandler } from './CredentialsHandler'; - -/** - * Returns a list of all credential tokens associated with this account. - * Note that this only returns the ID tokens, not the associated secrets. - */ -export class ListCredentialsHandler extends CredentialsHandler { - private readonly accountStore: AccountStore; - - public constructor(accountStore: AccountStore) { - super(); - this.accountStore = accountStore; - } - - public async handle({ operation, body: { webId }}: CredentialsHandlerInput): Promise { - const credentials = (await this.accountStore.getSettings(webId)).clientCredentials ?? []; - return new BasicRepresentation(JSON.stringify(credentials), operation.target, APPLICATION_JSON); - } -} diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts deleted file mode 100644 index 7c9a2fd06..000000000 --- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts +++ /dev/null @@ -1,86 +0,0 @@ -import assert from 'assert'; -import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; -import type { Representation } from '../../../../http/representation/Representation'; -import { getLoggerFor } from '../../../../logging/LogUtil'; -import { APPLICATION_JSON } from '../../../../util/ContentTypes'; -import { readJsonStream } from '../../../../util/StreamUtil'; -import type { TemplateEngine } from '../../../../util/templates/TemplateEngine'; -import { BaseInteractionHandler } from '../../BaseInteractionHandler'; -import type { InteractionHandlerInput } from '../../InteractionHandler'; -import type { InteractionRoute } from '../../routing/InteractionRoute'; -import type { AccountStore } from '../storage/AccountStore'; -import type { EmailSender } from '../util/EmailSender'; - -const forgotPasswordView = { - required: { - email: 'string', - }, -} as const; - -export interface ForgotPasswordHandlerArgs { - accountStore: AccountStore; - templateEngine: TemplateEngine<{ resetLink: string }>; - emailSender: EmailSender; - resetRoute: InteractionRoute; -} - -/** - * Handles the submission of the ForgotPassword form - */ -export class ForgotPasswordHandler extends BaseInteractionHandler { - protected readonly logger = getLoggerFor(this); - - private readonly accountStore: AccountStore; - private readonly templateEngine: TemplateEngine<{ resetLink: string }>; - private readonly emailSender: EmailSender; - private readonly resetRoute: InteractionRoute; - - public constructor(args: ForgotPasswordHandlerArgs) { - super(forgotPasswordView); - this.accountStore = args.accountStore; - this.templateEngine = args.templateEngine; - this.emailSender = args.emailSender; - this.resetRoute = args.resetRoute; - } - - public async handlePost({ operation }: InteractionHandlerInput): Promise { - // Validate incoming data - const { email } = await readJsonStream(operation.body.data); - assert(typeof email === 'string' && email.length > 0, 'Email required'); - - await this.resetPassword(email); - return new BasicRepresentation(JSON.stringify({ email }), operation.target, APPLICATION_JSON); - } - - /** - * Generates a record to reset the password for the given email address and then mails it. - * In case there is no account, no error wil be thrown for privacy reasons. - * Instead nothing will happen instead. - */ - private async resetPassword(email: string): Promise { - let recordId: string; - try { - recordId = await this.accountStore.generateForgotPasswordRecord(email); - } catch { - // Don't emit an error for privacy reasons - this.logger.warn(`Password reset request for unknown email ${email}`); - return; - } - await this.sendResetMail(recordId, email); - } - - /** - * Generates the link necessary for resetting the password and mails it to the given email address. - */ - private async sendResetMail(recordId: string, email: string): Promise { - this.logger.info(`Sending password reset to ${email}`); - const resetLink = `${this.resetRoute.getPath()}?rid=${encodeURIComponent(recordId)}`; - const renderedEmail = await this.templateEngine.handleSafe({ contents: { resetLink }}); - await this.emailSender.handleSafe({ - recipient: email, - subject: 'Reset your password', - text: `To reset your password, go to this link: ${resetLink}`, - html: renderedEmail, - }); - } -} diff --git a/src/identity/interaction/email-password/handler/LoginHandler.ts b/src/identity/interaction/email-password/handler/LoginHandler.ts deleted file mode 100644 index 42afb3ef4..000000000 --- a/src/identity/interaction/email-password/handler/LoginHandler.ts +++ /dev/null @@ -1,82 +0,0 @@ -import assert from 'assert'; -import type { InteractionResults } from '../../../../../templates/types/oidc-provider'; -import type { Operation } from '../../../../http/Operation'; -import { getLoggerFor } from '../../../../logging/LogUtil'; -import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; -import { FoundHttpError } from '../../../../util/errors/FoundHttpError'; -import { readJsonStream } from '../../../../util/StreamUtil'; -import { BaseInteractionHandler } from '../../BaseInteractionHandler'; -import type { InteractionHandlerInput } from '../../InteractionHandler'; -import type { AccountStore } from '../storage/AccountStore'; - -const loginView = { - required: { - email: 'string', - password: 'string', - remember: 'boolean', - }, -} as const; - -interface LoginInput { - email: string; - password: string; - remember: boolean; -} - -/** - * Handles the submission of the Login Form and logs the user in. - * Will throw a RedirectHttpError on success. - */ -export class LoginHandler extends BaseInteractionHandler { - protected readonly logger = getLoggerFor(this); - - private readonly accountStore: AccountStore; - - public constructor(accountStore: AccountStore) { - super(loginView); - this.accountStore = accountStore; - } - - public async canHandle(input: InteractionHandlerInput): Promise { - await super.canHandle(input); - if (input.operation.method === 'POST' && !input.oidcInteraction) { - throw new BadRequestHttpError( - 'This action can only be performed as part of an OIDC authentication flow.', - { errorCode: 'E0002' }, - ); - } - } - - public async handlePost({ operation, oidcInteraction }: InteractionHandlerInput): Promise { - const { email, password, remember } = await this.parseInput(operation); - // Try to log in, will error if email/password combination is invalid - const webId = await this.accountStore.authenticate(email, password); - const settings = await this.accountStore.getSettings(webId); - if (!settings.useIdp) { - // There is an account but is not used for identification with the IDP - throw new BadRequestHttpError('This server is not an identity provider for this account.'); - } - this.logger.debug(`Logging in user ${email}`); - - // Update the interaction to get the redirect URL - const login: InteractionResults['login'] = { - accountId: webId, - remember, - }; - oidcInteraction!.result = { login }; - await oidcInteraction!.save(oidcInteraction!.exp - Math.floor(Date.now() / 1000)); - - throw new FoundHttpError(oidcInteraction!.returnTo); - } - - /** - * Validates the input data. Also makes sure remember is a boolean. - * Will throw an error in case something is wrong. - */ - private async parseInput(operation: Operation): Promise { - const { email, password, remember } = await readJsonStream(operation.body.data); - assert(typeof email === 'string' && email.length > 0, 'Email required'); - assert(typeof password === 'string' && password.length > 0, 'Password required'); - return { email, password, remember: Boolean(remember) }; - } -} diff --git a/src/identity/interaction/email-password/handler/RegistrationHandler.ts b/src/identity/interaction/email-password/handler/RegistrationHandler.ts deleted file mode 100644 index a8ae66b5c..000000000 --- a/src/identity/interaction/email-password/handler/RegistrationHandler.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; -import type { Representation } from '../../../../http/representation/Representation'; -import { getLoggerFor } from '../../../../logging/LogUtil'; -import { APPLICATION_JSON } from '../../../../util/ContentTypes'; -import { readJsonStream } from '../../../../util/StreamUtil'; -import { BaseInteractionHandler } from '../../BaseInteractionHandler'; -import type { InteractionHandlerInput } from '../../InteractionHandler'; -import type { RegistrationManager } from '../util/RegistrationManager'; - -const registrationView = { - required: { - email: 'string', - password: 'string', - confirmPassword: 'string', - createWebId: 'boolean', - register: 'boolean', - createPod: 'boolean', - rootPod: 'boolean', - }, - optional: { - webId: 'string', - podName: 'string', - template: 'string', - }, -} as const; - -/** - * Supports registration based on the `RegistrationManager` behaviour. - */ -export class RegistrationHandler extends BaseInteractionHandler { - protected readonly logger = getLoggerFor(this); - - private readonly registrationManager: RegistrationManager; - - public constructor(registrationManager: RegistrationManager) { - super(registrationView); - this.registrationManager = registrationManager; - } - - public async handlePost({ operation }: InteractionHandlerInput): Promise { - const data = await readJsonStream(operation.body.data); - const validated = this.registrationManager.validateInput(data, false); - const details = await this.registrationManager.register(validated, false); - return new BasicRepresentation(JSON.stringify(details), operation.target, APPLICATION_JSON); - } -} diff --git a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts deleted file mode 100644 index 5f9fe520e..000000000 --- a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts +++ /dev/null @@ -1,57 +0,0 @@ -import assert from 'assert'; -import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation'; -import type { Representation } from '../../../../http/representation/Representation'; -import { getLoggerFor } from '../../../../logging/LogUtil'; -import { APPLICATION_JSON } from '../../../../util/ContentTypes'; -import { readJsonStream } from '../../../../util/StreamUtil'; -import { BaseInteractionHandler } from '../../BaseInteractionHandler'; -import type { InteractionHandlerInput } from '../../InteractionHandler'; -import { assertPassword } from '../EmailPasswordUtil'; -import type { AccountStore } from '../storage/AccountStore'; - -const resetPasswordView = { - required: { - password: 'string', - confirmPassword: 'string', - recordId: 'string', - }, -} as const; - -/** - * Resets a password if a valid `recordId` is provided, - * which should have been generated by a different handler. - */ -export class ResetPasswordHandler extends BaseInteractionHandler { - protected readonly logger = getLoggerFor(this); - - private readonly accountStore: AccountStore; - - public constructor(accountStore: AccountStore) { - super(resetPasswordView); - this.accountStore = accountStore; - } - - public async handlePost({ operation }: InteractionHandlerInput): Promise { - // Validate input data - const { password, confirmPassword, recordId } = await readJsonStream(operation.body.data); - assert( - typeof recordId === 'string' && recordId.length > 0, - 'Invalid request. Open the link from your email again', - ); - assertPassword(password, confirmPassword); - - await this.resetPassword(recordId, password); - return new BasicRepresentation(JSON.stringify({}), operation.target, APPLICATION_JSON); - } - - /** - * Resets the password for the account associated with the given recordId. - */ - private async resetPassword(recordId: string, newPassword: string): Promise { - const email = await this.accountStore.getForgotPasswordRecord(recordId); - assert(email, 'This reset password link is no longer valid.'); - await this.accountStore.deleteForgotPasswordRecord(recordId); - await this.accountStore.changePassword(email, newPassword); - this.logger.debug(`Resetting password for user ${email}`); - } -} diff --git a/src/identity/interaction/email-password/storage/AccountStore.ts b/src/identity/interaction/email-password/storage/AccountStore.ts deleted file mode 100644 index bdbf3dcf8..000000000 --- a/src/identity/interaction/email-password/storage/AccountStore.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Options that can be set on an account. - */ -export interface AccountSettings { - /** - * If this account can be used to identify as the corresponding WebID in the IDP. - */ - useIdp: boolean; - /** - * The base URL of the pod associated with this account, if there is one. - */ - podBaseUrl?: string; - /** - * All credential tokens associated with this account. - */ - clientCredentials?: string[]; -} - -/** - * Storage needed for the email-password interaction - */ -export interface AccountStore { - /** - * Authenticate if the username and password are correct and return the WebID - * if it is. Throw an error if it is not. - * @param email - The user's email. - * @param password - This user's password. - * @returns The user's WebID. - */ - authenticate: (email: string, password: string) => Promise; - - /** - * Creates a new account. - * @param email - Account email. - * @param webId - Account WebID. - * @param password - Account password. - * @param settings - Specific settings for the account. - */ - create: (email: string, webId: string, password: string, settings: AccountSettings) => Promise; - - /** - * Verifies the account creation. This can be used with, for example, e-mail verification. - * The account can only be used after it is verified. - * In case verification is not required, this should be called immediately after the `create` call. - * @param email - The account email. - */ - verify: (email: string) => Promise; - - /** - * Changes the password. - * @param email - The user's email. - * @param password - The user's password. - */ - changePassword: (email: string, password: string) => Promise; - - /** - * Gets the settings associated with this account. - * Errors if there is no matching account. - * @param webId - The account WebID. - */ - getSettings: (webId: string) => Promise; - - /** - * Updates the settings associated with this account. - * @param webId - The account WebID. - * @param settings - New settings for the account. - */ - updateSettings: (webId: string, settings: AccountSettings) => Promise; - - /** - * Delete the account. - * @param email - The user's email. - */ - deleteAccount: (email: string) => Promise; - - /** - * Creates a Forgot Password Confirmation Record. This will be to remember that - * a user has made a request to reset a password. Throws an error if the email doesn't - * exist - * @param email - The user's email. - * @returns The record id. This should be included in the reset password link. - */ - generateForgotPasswordRecord: (email: string) => Promise; - - /** - * Gets the email associated with the forgot password confirmation record or undefined - * if it's not present - * @param recordId - The record id retrieved from the link. - * @returns The user's email. - */ - getForgotPasswordRecord: (recordId: string) => Promise; - - /** - * Deletes the Forgot Password Confirmation Record - * @param recordId - The record id of the forgot password confirmation record. - */ - deleteForgotPasswordRecord: (recordId: string) => Promise; -} diff --git a/src/identity/interaction/email-password/storage/BaseAccountStore.ts b/src/identity/interaction/email-password/storage/BaseAccountStore.ts deleted file mode 100644 index 7bc511c4f..000000000 --- a/src/identity/interaction/email-password/storage/BaseAccountStore.ts +++ /dev/null @@ -1,161 +0,0 @@ -import assert from 'assert'; -import { hash, compare } from 'bcryptjs'; -import { v4 } from 'uuid'; -import type { ExpiringStorage } from '../../../../storage/keyvalue/ExpiringStorage'; -import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; -import type { AccountSettings, AccountStore } from './AccountStore'; - -/** - * A payload to persist a user account - */ -export interface AccountPayload { - webId: string; - email: string; - password: string; - verified: boolean; -} - -/** - * A payload to persist the fact that a user - * has requested to reset their password - */ -export interface ForgotPasswordPayload { - email: string; - recordId: string; -} - -export type EmailPasswordData = AccountPayload | ForgotPasswordPayload | AccountSettings; - -/** - * A EmailPasswordStore that uses a KeyValueStorage to persist its information and an - * ExpiringStorage to persist ForgotPassword records. - * - * `forgotPasswordExpiration` parameter is how long the ForgotPassword record should be - * stored in minutes. *(defaults to 15 minutes)* - */ -export class BaseAccountStore implements AccountStore { - private readonly storage: KeyValueStorage; - private readonly forgotPasswordStorage: ExpiringStorage; - private readonly saltRounds: number; - private readonly forgotPasswordExpiration: number; - - public constructor(storage: KeyValueStorage, - forgotPasswordStorage: ExpiringStorage, - saltRounds: number, - forgotPasswordExpiration = 15) { - this.storage = storage; - this.forgotPasswordStorage = forgotPasswordStorage; - this.forgotPasswordExpiration = forgotPasswordExpiration * 60 * 1000; - this.saltRounds = saltRounds; - } - - /** - * Generates a ResourceIdentifier to store data for the given email. - */ - private getAccountResourceIdentifier(email: string): string { - return `account/${encodeURIComponent(email)}`; - } - - /** - * Generates a ResourceIdentifier to store data for the given recordId. - */ - private getForgotPasswordRecordResourceIdentifier(recordId: string): string { - return `forgot-password-resource-identifier/${encodeURIComponent(recordId)}`; - } - - /* eslint-disable lines-between-class-members */ - /** - * Helper function that converts the given e-mail to an account identifier - * and retrieves the account data from the internal storage. - * - * Will error if `checkExistence` is true and the account does not exist. - */ - private async getAccountPayload(email: string, checkExistence: true): - Promise<{ key: string; account: AccountPayload }>; - private async getAccountPayload(email: string, checkExistence: false): - Promise<{ key: string; account?: AccountPayload }>; - private async getAccountPayload(email: string, checkExistence: boolean): - Promise<{ key: string; account?: AccountPayload }> { - const key = this.getAccountResourceIdentifier(email); - const account = await this.storage.get(key) as AccountPayload | undefined; - assert(!checkExistence || account, 'Account does not exist'); - return { key, account }; - } - /* eslint-enable lines-between-class-members */ - - public async authenticate(email: string, password: string): Promise { - const { account } = await this.getAccountPayload(email, true); - assert(account.verified, 'Account still needs to be verified'); - assert(await compare(password, account.password), 'Incorrect password'); - return account.webId; - } - - public async create(email: string, webId: string, password: string, settings: AccountSettings): Promise { - const { key, account } = await this.getAccountPayload(email, false); - assert(!account, 'Account already exists'); - // Make sure there is no other account for this WebID - const storedSettings = await this.storage.get(webId); - assert(!storedSettings, 'There already is an account for this WebID'); - const payload: AccountPayload = { - email, - password: await hash(password, this.saltRounds), - verified: false, - webId, - }; - await this.storage.set(key, payload); - await this.storage.set(webId, settings); - } - - public async verify(email: string): Promise { - const { key, account } = await this.getAccountPayload(email, true); - account.verified = true; - await this.storage.set(key, account); - } - - public async changePassword(email: string, password: string): Promise { - const { key, account } = await this.getAccountPayload(email, true); - account.password = await hash(password, this.saltRounds); - await this.storage.set(key, account); - } - - public async getSettings(webId: string): Promise { - const settings = await this.storage.get(webId) as AccountSettings | undefined; - assert(settings, 'Account does not exist'); - return settings; - } - - public async updateSettings(webId: string, settings: AccountSettings): Promise { - const oldSettings = await this.storage.get(webId); - assert(oldSettings, 'Account does not exist'); - await this.storage.set(webId, settings); - } - - public async deleteAccount(email: string): Promise { - const { key, account } = await this.getAccountPayload(email, false); - if (account) { - await this.storage.delete(key); - await this.storage.delete(account.webId); - } - } - - public async generateForgotPasswordRecord(email: string): Promise { - const recordId = v4(); - await this.getAccountPayload(email, true); - await this.forgotPasswordStorage.set( - this.getForgotPasswordRecordResourceIdentifier(recordId), - { recordId, email }, - this.forgotPasswordExpiration, - ); - return recordId; - } - - public async getForgotPasswordRecord(recordId: string): Promise { - const identifier = this.getForgotPasswordRecordResourceIdentifier(recordId); - const forgotPasswordRecord = await this.forgotPasswordStorage.get(identifier) as ForgotPasswordPayload | undefined; - return forgotPasswordRecord?.email; - } - - public async deleteForgotPasswordRecord(recordId: string): Promise { - await this.forgotPasswordStorage.delete(this.getForgotPasswordRecordResourceIdentifier(recordId)); - } -} diff --git a/src/identity/interaction/email-password/util/RegistrationManager.ts b/src/identity/interaction/email-password/util/RegistrationManager.ts deleted file mode 100644 index 1abc9908d..000000000 --- a/src/identity/interaction/email-password/util/RegistrationManager.ts +++ /dev/null @@ -1,239 +0,0 @@ -import assert from 'assert'; -import type { ResourceIdentifier } from '../../../../http/representation/ResourceIdentifier'; -import { getLoggerFor } from '../../../../logging/LogUtil'; -import type { IdentifierGenerator } from '../../../../pods/generate/IdentifierGenerator'; -import type { PodManager } from '../../../../pods/PodManager'; -import type { PodSettings } from '../../../../pods/settings/PodSettings'; -import { hasScheme } from '../../../../util/HeaderUtil'; -import { joinUrl } from '../../../../util/PathUtil'; -import type { OwnershipValidator } from '../../../ownership/OwnershipValidator'; -import { assertPassword } from '../EmailPasswordUtil'; -import type { AccountSettings, AccountStore } from '../storage/AccountStore'; - -export interface RegistrationManagerArgs { - /** - * Used to set the `oidcIssuer` value of newly registered pods. - */ - baseUrl: string; - /** - * Appended to the generated pod identifier to create the corresponding WebID. - */ - webIdSuffix: string; - /** - * Generates identifiers for new pods. - */ - identifierGenerator: IdentifierGenerator; - /** - * Verifies the user is the owner of the WebID they provide. - */ - ownershipValidator: OwnershipValidator; - /** - * Stores all the registered account information. - */ - accountStore: AccountStore; - /** - * Creates the new pods. - */ - podManager: PodManager; -} - -/** - * The parameters expected for registration. - */ -export interface RegistrationParams { - email: string; - webId?: string; - password: string; - podName?: string; - template?: string; - createWebId: boolean; - register: boolean; - createPod: boolean; - rootPod: boolean; -} - -/** - * The result of a registration action. - */ -export interface RegistrationResponse { - email: string; - webId?: string; - oidcIssuer?: string; - podBaseUrl?: string; - createWebId: boolean; - register: boolean; - createPod: boolean; -} - -const emailRegex = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/u; - -/** - * Supports IDP registration and pod creation based on input parameters. - * - * The above behaviour is combined in the two class functions. - * `validateInput` will make sure all incoming data is correct and makes sense. - * `register` will call all the correct handlers based on the requirements of the validated parameters. - */ -export class RegistrationManager { - protected readonly logger = getLoggerFor(this); - - private readonly baseUrl: string; - private readonly webIdSuffix: string; - private readonly identifierGenerator: IdentifierGenerator; - private readonly ownershipValidator: OwnershipValidator; - private readonly accountStore: AccountStore; - private readonly podManager: PodManager; - - public constructor(args: RegistrationManagerArgs) { - this.baseUrl = args.baseUrl; - this.webIdSuffix = args.webIdSuffix; - this.identifierGenerator = args.identifierGenerator; - this.ownershipValidator = args.ownershipValidator; - this.accountStore = args.accountStore; - this.podManager = args.podManager; - } - - /** - * Trims the input if it is a string, returns `undefined` otherwise. - */ - private trimString(input: unknown): string | undefined { - if (typeof input === 'string') { - return input.trim(); - } - } - - /** - * Makes sure the input conforms to the following requirements when relevant: - * * At least one option needs to be chosen. - * * In case a new WebID needs to be created, the other 2 steps will be set to true. - * * Valid email/WebID/password/podName when required. - * * Only create a root pod when allowed. - * - * @param input - Input parameters for the registration procedure. - * @param allowRootPod - If creating a pod in the root container should be allowed. - * - * @returns A cleaned up version of the input parameters. - * Only (trimmed) parameters that are relevant to the registration procedure will be retained. - */ - public validateInput(input: NodeJS.Dict, allowRootPod: boolean): RegistrationParams { - const { - email, password, confirmPassword, webId, podName, register, createPod, createWebId, template, rootPod, - } = input; - - // Parse email - const trimmedEmail = this.trimString(email); - assert(trimmedEmail && emailRegex.test(trimmedEmail), 'Please enter a valid e-mail address.'); - - assertPassword(password, confirmPassword); - - const validated: RegistrationParams = { - email: trimmedEmail, - password, - register: Boolean(register) || Boolean(createWebId), - createPod: Boolean(createPod) || Boolean(createWebId), - createWebId: Boolean(createWebId), - rootPod: Boolean(rootPod), - }; - assert(validated.register || validated.createPod, 'Please register for a WebID or create a Pod.'); - assert(allowRootPod || !validated.rootPod, 'Creating a root pod is not supported.'); - - // Parse WebID - if (!validated.createWebId) { - const trimmedWebId = this.trimString(webId); - assert(trimmedWebId && hasScheme(trimmedWebId, 'http', 'https'), 'Please enter a valid WebID.'); - validated.webId = trimmedWebId; - } - - // Parse Pod name - if (validated.createPod && !validated.rootPod) { - const trimmedPodName = this.trimString(podName); - assert(trimmedPodName && trimmedPodName.length > 0, 'Please specify a Pod name.'); - validated.podName = trimmedPodName; - } - - // Parse template if there is one - if (template) { - validated.template = this.trimString(template); - } - - return validated; - } - - /** - * Handles the 3 potential steps of the registration process: - * 1. Generating a new WebID. - * 2. Registering a WebID with the IDP. - * 3. Creating a new pod for a given WebID. - * - * All of these steps are optional and will be determined based on the input parameters. - * - * This includes the following steps: - * * Ownership will be verified when the WebID is provided. - * * When registering and creating a pod, the base URL will be used as oidcIssuer value. - */ - public async register(input: RegistrationParams, allowRootPod: boolean): Promise { - // This is only used when createWebId and/or createPod are true - let podBaseUrl: ResourceIdentifier | undefined; - if (input.createPod) { - if (input.rootPod) { - podBaseUrl = { path: this.baseUrl }; - } else { - podBaseUrl = this.identifierGenerator.generate(input.podName!); - } - } - - // Create or verify the WebID - if (input.createWebId) { - input.webId = joinUrl(podBaseUrl!.path, this.webIdSuffix); - } else { - await this.ownershipValidator.handleSafe({ webId: input.webId! }); - } - - // Register the account - const settings: AccountSettings = { - useIdp: input.register, - podBaseUrl: podBaseUrl?.path, - clientCredentials: [], - }; - await this.accountStore.create(input.email, input.webId!, input.password, settings); - - // Create the pod - if (input.createPod) { - const podSettings: PodSettings = { - email: input.email, - webId: input.webId!, - template: input.template, - podBaseUrl: podBaseUrl!.path, - }; - - // Set the OIDC issuer to our server when registering with the IDP - if (input.register) { - podSettings.oidcIssuer = this.baseUrl; - } - - try { - // Only allow overwrite for root pods - await this.podManager.createPod(podBaseUrl!, podSettings, allowRootPod); - } catch (error: unknown) { - await this.accountStore.deleteAccount(input.email); - throw error; - } - } - - // Verify the account - // This prevents there being a small timeframe where the account can be used before the pod creation is finished. - // That timeframe could potentially be used by malicious users. - await this.accountStore.verify(input.email); - - return { - webId: input.webId, - email: input.email, - oidcIssuer: this.baseUrl, - podBaseUrl: podBaseUrl?.path, - createWebId: input.createWebId, - register: input.register, - createPod: input.createPod, - }; - } -} - diff --git a/src/identity/interaction/login/LogoutHandler.ts b/src/identity/interaction/login/LogoutHandler.ts new file mode 100644 index 000000000..b29422acd --- /dev/null +++ b/src/identity/interaction/login/LogoutHandler.ts @@ -0,0 +1,43 @@ +import { RepresentationMetadata } from '../../../http/representation/RepresentationMetadata'; +import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; +import type { EmptyObject } from '../../../util/map/MapUtil'; +import { SOLID_HTTP } from '../../../util/Vocabularies'; +import type { CookieStore } from '../account/util/CookieStore'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; + +/** + * Responsible for logging a user out. + * In practice this means making sure the cookie is no longer valid. + */ +export class LogoutHandler extends JsonInteractionHandler { + private readonly cookieStore: CookieStore; + + public constructor(cookieStore: CookieStore) { + super(); + this.cookieStore = cookieStore; + } + + public async handle(input: JsonInteractionHandlerInput): Promise> { + const { metadata, accountId, target } = input; + const cookie = metadata.get(SOLID_HTTP.terms.accountCookie)?.value; + if (cookie) { + // Make sure the cookie belongs to the logged-in user. + const foundId = await this.cookieStore.get(cookie); + if (foundId !== accountId) { + throw new BadRequestHttpError('Invalid cookie.'); + } + + await this.cookieStore.delete(cookie); + + // Setting the expiration time of a cookie to somewhere in the past causes browsers to delete that cookie + const outputMetadata = new RepresentationMetadata(target); + outputMetadata.set(SOLID_HTTP.terms.accountCookie, cookie); + outputMetadata.set(SOLID_HTTP.terms.accountCookieExpiration, new Date(0).toISOString()); + return { json: {}, metadata: outputMetadata }; + } + + return { json: {}}; + } +} diff --git a/src/identity/interaction/login/ResolveLoginHandler.ts b/src/identity/interaction/login/ResolveLoginHandler.ts new file mode 100644 index 000000000..8826cef41 --- /dev/null +++ b/src/identity/interaction/login/ResolveLoginHandler.ts @@ -0,0 +1,114 @@ +import { RepresentationMetadata } from '../../../http/representation/RepresentationMetadata'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import { InternalServerError } from '../../../util/errors/InternalServerError'; +import { SOLID_HTTP } from '../../../util/Vocabularies'; +import type { AccountIdRoute } from '../account/AccountIdRoute'; +import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from '../account/util/Account'; +import type { AccountStore } from '../account/util/AccountStore'; +import type { CookieStore } from '../account/util/CookieStore'; +import type { Json, JsonRepresentation } from '../InteractionUtil'; +import { finishInteraction } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; + +/** + * Output type that is expected of handlers logging an account in. + */ +export type LoginOutputType = { + /** + * The ID of the account that logged in. + */ + accountId: string; + /** + * If this account should be remembered or not. + * Setting this to `undefined` will keep the setting as it currently is. + */ + remember?: boolean; +}; + +/** + * A handler that takes care of all the necessary steps when logging a user in, + * such as generating a cookie and setting the necessary OIDC information. + * It also sets the `resource` field of the response to the account URL. + * Classes that resolve login methods should extend this class and implement the `login` method. + */ +export abstract class ResolveLoginHandler extends JsonInteractionHandler { + protected readonly logger = getLoggerFor(this); + + protected readonly accountStore: AccountStore; + protected readonly cookieStore: CookieStore; + protected readonly accountRoute: AccountIdRoute; + + protected constructor(accountStore: AccountStore, cookieStore: CookieStore, accountRoute: AccountIdRoute) { + super(); + this.accountStore = accountStore; + this.cookieStore = cookieStore; + this.accountRoute = accountRoute; + } + + public async handle(input: JsonInteractionHandlerInput): Promise { + const result = await this.login(input); + const { accountId, remember } = result.json; + + const json: Json = { + ...result.json, + resource: this.accountRoute.getPath({ accountId }), + }; + + // There is no need to output these fields in the response JSON + delete json.accountId; + delete json.remember; + + // The cookie that is used to identify that a user has logged in. + // Putting it in the metadata, so it can be converted into an HTTP response header. + // Putting it in the response JSON so users can also use it in an Authorization header. + const metadata = result.metadata ?? new RepresentationMetadata(input.target); + json.cookie = await this.cookieStore.generate(accountId); + metadata.add(SOLID_HTTP.terms.accountCookie, json.cookie); + + // Delete the old cookie if there was one, to prevent unused cookies from being stored. + // We are not reusing this cookie as it could be associated with a different account. + const oldCookie = input.metadata.get(SOLID_HTTP.terms.accountCookie)?.value; + if (oldCookie) { + this.logger.debug(`Replacing old cookie ${oldCookie} with ${json.cookie}`); + await this.cookieStore.delete(oldCookie); + } + + // Update the account settings + await this.updateRememberSetting(accountId, remember); + + // Not throwing redirect error otherwise the cookie metadata would be lost. + // See {@link LocationInteractionHandler} why this field is added. + if (input.oidcInteraction) { + // Finish the interaction so the policies are checked again, where they will find the new cookie + json.location = await finishInteraction(input.oidcInteraction, {}, true); + } + + return { json, metadata }; + } + + /** + * Updates the account setting that determines if the login status needs to be remembered. + * @param accountId - ID of the account. + * @param remember - If the account should be remembered or not. The setting will not be updated if this is undefined. + */ + protected async updateRememberSetting(accountId: string, remember?: boolean): Promise { + if (typeof remember === 'boolean') { + // Store the setting indicating if the user wants the cookie to persist + const account = await this.accountStore.get(accountId); + if (!account) { + this.logger.error(`Unable to find account ${accountId} that just logged in.`); + throw new InternalServerError('Unable to find account'); + } + account.settings[ACCOUNT_SETTINGS_REMEMBER_LOGIN] = remember; + await this.accountStore.update(account); + this.logger.debug(`Updating account remember setting to ${remember}`); + } + } + + /** + * Takes the necessary steps to log a user in. + * @param input - Same input that was passed to the handle function. + */ + public abstract login(input: JsonInteractionHandlerInput): Promise>; +} diff --git a/src/identity/interaction/oidc/CancelOidcHandler.ts b/src/identity/interaction/oidc/CancelOidcHandler.ts new file mode 100644 index 000000000..d81c38d0a --- /dev/null +++ b/src/identity/interaction/oidc/CancelOidcHandler.ts @@ -0,0 +1,23 @@ +import { FoundHttpError } from '../../../util/errors/FoundHttpError'; +import { assertOidcInteraction, finishInteraction } from '../InteractionUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; + +/** + * Cancel an active OIDC interaction. + */ +export class CancelOidcHandler extends JsonInteractionHandler { + public async handle({ oidcInteraction }: JsonInteractionHandlerInput): Promise> { + assertOidcInteraction(oidcInteraction); + const error = { + error: 'access_denied', + // eslint-disable-next-line @typescript-eslint/naming-convention + error_description: 'User cancelled the interaction.', + }; + + const location = await finishInteraction(oidcInteraction, error, false); + + throw new FoundHttpError(location); + } +} diff --git a/src/identity/interaction/oidc/ClientInfoHandler.ts b/src/identity/interaction/oidc/ClientInfoHandler.ts new file mode 100644 index 000000000..8be1efc6d --- /dev/null +++ b/src/identity/interaction/oidc/ClientInfoHandler.ts @@ -0,0 +1,52 @@ +import type { AllClientMetadata } from '../../../../templates/types/oidc-provider'; +import type { ArrayElement } from '../../../util/map/MapUtil'; +import type { ProviderFactory } from '../../configuration/ProviderFactory'; +import type { JsonRepresentation } from '../InteractionUtil'; +import { assertOidcInteraction } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; + +// Only extract specific fields to prevent leaking information +// Based on https://www.w3.org/ns/solid/oidc-context.jsonld +const CLIENT_KEYS = [ 'client_id', 'client_uri', 'logo_uri', 'policy_uri', + 'client_name', 'contacts', 'grant_types', 'scope' ] as const; + +// Possible keys in client metadata +type KeyType = ArrayElement; +// Possible values for client metadata +type ValType = AllClientMetadata[KeyType]; +// Simplified to keep Components.js happy +type OutType = { + client: Record; + webId?: string; +}; + +/** + * Returns a JSON representation with metadata of the client that is requesting the OIDC interaction. + */ +export class ClientInfoHandler extends JsonInteractionHandler { + private readonly providerFactory: ProviderFactory; + + public constructor(providerFactory: ProviderFactory) { + super(); + this.providerFactory = providerFactory; + } + + public async handle({ oidcInteraction }: JsonInteractionHandlerInput): Promise> { + assertOidcInteraction(oidcInteraction); + const provider = await this.providerFactory.getProvider(); + const client = await provider.Client.find(oidcInteraction.params.client_id as string); + const metadata: AllClientMetadata = client?.metadata() ?? {}; + + const jsonLd = Object.fromEntries( + CLIENT_KEYS.filter((key): boolean => key in metadata) + .map((key): [ KeyType, ValType ] => [ key, metadata[key] ]), + ) as Record & { '@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld' }; + jsonLd['@context'] = 'https://www.w3.org/ns/solid/oidc-context.jsonld'; + + // Note: this is the `accountId` from the OIDC library, in which we store the WebID + const webId = oidcInteraction?.session?.accountId; + + return { json: { client: jsonLd, webId }}; + } +} diff --git a/src/identity/interaction/oidc/ConsentHandler.ts b/src/identity/interaction/oidc/ConsentHandler.ts new file mode 100644 index 000000000..d67c0eca1 --- /dev/null +++ b/src/identity/interaction/oidc/ConsentHandler.ts @@ -0,0 +1,104 @@ +import { boolean, object } from 'yup'; +import type { InteractionResults, KoaContextWithOIDC, UnknownObject } from '../../../../templates/types/oidc-provider'; +import { FoundHttpError } from '../../../util/errors/FoundHttpError'; +import { NotImplementedHttpError } from '../../../util/errors/NotImplementedHttpError'; +import type { ProviderFactory } from '../../configuration/ProviderFactory'; +import type { Interaction } from '../InteractionHandler'; +import { assertOidcInteraction, finishInteraction } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import { validateWithError } from '../YupUtil'; + +type Grant = NonNullable; + +const inSchema = object({ + remember: boolean().default(false), +}); + +/** + * Handles the OIDC consent prompts where the user confirms they want to log in for the given client. + */ +export class ConsentHandler extends JsonInteractionHandler { + private readonly providerFactory: ProviderFactory; + + public constructor(providerFactory: ProviderFactory) { + super(); + this.providerFactory = providerFactory; + } + + public async handle({ oidcInteraction, json }: JsonInteractionHandlerInput): Promise { + assertOidcInteraction(oidcInteraction); + + const { remember } = await validateWithError(inSchema, json); + + const grant = await this.getGrant(oidcInteraction); + this.updateGrant(grant, oidcInteraction.prompt.details, remember); + + const location = await this.updateInteraction(oidcInteraction, grant); + + throw new FoundHttpError(location); + } + + /** + * Either returns the grant associated with the given interaction or creates a new one if it does not exist yet. + */ + private async getGrant(oidcInteraction: Interaction): Promise { + if (!oidcInteraction.session) { + throw new NotImplementedHttpError('Only interactions with a valid session are supported.'); + } + + const { params, session: { accountId }, grantId } = oidcInteraction; + const provider = await this.providerFactory.getProvider(); + + let grant: Grant; + if (grantId) { + grant = (await provider.Grant.find(grantId))!; + } else { + grant = new provider.Grant({ + accountId, + clientId: params.client_id as string, + }); + } + return grant; + } + + /** + * Updates the grant with all the missing scopes and claims requested by the interaction. + * + * Will reject the `offline_access` scope if `remember` is false. + */ + private updateGrant(grant: Grant, details: UnknownObject, remember: boolean): void { + // Reject the offline_access scope if the user does not want to be remembered + if (!remember) { + grant.rejectOIDCScope('offline_access'); + } + + // Grant all the requested scopes and claims + if (details.missingOIDCScope) { + grant.addOIDCScope((details.missingOIDCScope as string[]).join(' ')); + } + if (details.missingOIDCClaims) { + grant.addOIDCClaims(details.missingOIDCClaims as string[]); + } + if (details.missingResourceScopes) { + for (const [ indicator, scopes ] of Object.entries(details.missingResourceScopes as Record)) { + grant.addResourceScope(indicator, scopes.join(' ')); + } + } + } + + /** + * Updates the interaction with the new grant and returns the resulting redirect URL. + */ + private async updateInteraction(oidcInteraction: Interaction, grant: Grant): Promise { + const grantId = await grant.save(); + + const consent: InteractionResults['consent'] = {}; + // Only need to update the grantId if it is new + if (!oidcInteraction.grantId) { + consent.grantId = grantId; + } + + return finishInteraction(oidcInteraction, { consent }, true); + } +} diff --git a/src/identity/interaction/oidc/ForgetWebIdHandler.ts b/src/identity/interaction/oidc/ForgetWebIdHandler.ts new file mode 100644 index 000000000..912381328 --- /dev/null +++ b/src/identity/interaction/oidc/ForgetWebIdHandler.ts @@ -0,0 +1,30 @@ +import { FoundHttpError } from '../../../util/errors/FoundHttpError'; +import type { ProviderFactory } from '../../configuration/ProviderFactory'; +import { assertOidcInteraction, finishInteraction, forgetWebId } from '../InteractionUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; + +/** + * Forgets the chosen WebID in an OIDC interaction, + * causing the next policy trigger to be one where a new WebID has to be chosen. + */ +export class ForgetWebIdHandler extends JsonInteractionHandler { + private readonly providerFactory: ProviderFactory; + + public constructor(providerFactory: ProviderFactory) { + super(); + this.providerFactory = providerFactory; + } + + public async handle({ oidcInteraction }: JsonInteractionHandlerInput): Promise> { + assertOidcInteraction(oidcInteraction); + + await forgetWebId(await this.providerFactory.getProvider(), oidcInteraction); + + // Finish the interaction so the policies get checked again + const location = await finishInteraction(oidcInteraction, {}, false); + + throw new FoundHttpError(location); + } +} diff --git a/src/identity/interaction/oidc/PickWebIdHandler.ts b/src/identity/interaction/oidc/PickWebIdHandler.ts new file mode 100644 index 000000000..981c3668c --- /dev/null +++ b/src/identity/interaction/oidc/PickWebIdHandler.ts @@ -0,0 +1,72 @@ +import { boolean, object, string } from 'yup'; +import type { InteractionResults } from '../../../../templates/types/oidc-provider'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../../../util/errors/FoundHttpError'; +import type { ProviderFactory } from '../../configuration/ProviderFactory'; +import type { AccountStore } from '../account/util/AccountStore'; +import { getRequiredAccount } from '../account/util/AccountUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import { assertOidcInteraction, finishInteraction, forgetWebId } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { JsonView } from '../JsonView'; +import { parseSchema, validateWithError } from '../YupUtil'; + +const inSchema = object({ + webId: string().trim().required(), + remember: boolean().default(false), +}); + +/** + * Allows users to choose which WebID they want to authenticate as during an OIDC interaction. + * + * One of the main reason picking a WebID is a separate class/request from consenting to the OIDC interaction, + * is because the OIDC-provider will only give the information we need for consent + * once we have added an accountId to the OIDC interaction, which we do in this class. + * The library also really wants to use that accountId as the value that you use for generating the tokens, + * meaning we can't just use another value there, so we need to assign the WebID to it, + * unless we use a hacky workaround. + */ +export class PickWebIdHandler extends JsonInteractionHandler implements JsonView { + private readonly logger = getLoggerFor(this); + + private readonly accountStore: AccountStore; + private readonly providerFactory: ProviderFactory; + + public constructor(accountStore: AccountStore, providerFactory: ProviderFactory) { + super(); + this.accountStore = accountStore; + this.providerFactory = providerFactory; + } + + public async getView({ accountId }: JsonInteractionHandlerInput): Promise { + const account = await getRequiredAccount(this.accountStore, accountId); + const description = parseSchema(inSchema); + return { json: { ...description, webIds: Object.keys(account.webIds) }}; + } + + public async handle({ oidcInteraction, accountId, json }: JsonInteractionHandlerInput): Promise { + assertOidcInteraction(oidcInteraction); + const account = await getRequiredAccount(this.accountStore, accountId); + + const { webId, remember } = await validateWithError(inSchema, json); + if (!account.webIds[webId]) { + this.logger.warn(`Trying to pick WebID ${webId} which does not belong to account ${accountId}`); + throw new BadRequestHttpError('WebID does not belong to this account.'); + } + + // We need to explicitly forget the WebID from the session or the library won't allow us to update the value + await forgetWebId(await this.providerFactory.getProvider(), oidcInteraction); + + // Update the interaction to get the redirect URL + const login: InteractionResults['login'] = { + // Note that `accountId` here is unrelated to our user accounts but is part of the OIDC library + accountId: webId, + remember, + }; + + const location = await finishInteraction(oidcInteraction, { login }, true); + throw new FoundHttpError(location); + } +} diff --git a/src/identity/interaction/oidc/PromptHandler.ts b/src/identity/interaction/oidc/PromptHandler.ts new file mode 100644 index 000000000..20a0fed74 --- /dev/null +++ b/src/identity/interaction/oidc/PromptHandler.ts @@ -0,0 +1,41 @@ +import { getLoggerFor } from '../../../logging/LogUtil'; +import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { InteractionRoute } from '../routing/InteractionRoute'; + +type OutType = { location: string; prompt: string }; + +/** + * Redirects requests based on the OIDC Interaction prompt. + * Errors in case no match was found. + * + * The reason we use this intermediate handler + * instead of letting the OIDC library redirect directly to the correct page, + * is because that library creates a cookie with of scope of the target page. + * By having the library always redirect to the index page, + * the cookie is relevant for all pages and other pages can see if we are still in an interaction. + */ +export class PromptHandler extends JsonInteractionHandler { + private readonly logger = getLoggerFor(this); + + private readonly promptRoutes: Record; + + public constructor(promptRoutes: Record) { + super(); + this.promptRoutes = promptRoutes; + } + + public async handle({ oidcInteraction }: JsonInteractionHandlerInput): Promise> { + const prompt = oidcInteraction?.prompt.name; + if (prompt && this.promptRoutes[prompt]) { + const location = this.promptRoutes[prompt].getPath(); + this.logger.debug(`Current prompt is ${prompt} with URL ${location}`); + // Not throwing redirect error since we also want to the prompt to the output json. + return { json: { location, prompt }}; + } + this.logger.warn(`Received unsupported prompt ${prompt}`); + throw new BadRequestHttpError(`Unsupported prompt: ${prompt}`); + } +} diff --git a/src/identity/interaction/password/CreatePasswordHandler.ts b/src/identity/interaction/password/CreatePasswordHandler.ts new file mode 100644 index 000000000..b8c3f19bb --- /dev/null +++ b/src/identity/interaction/password/CreatePasswordHandler.ts @@ -0,0 +1,76 @@ +import { object, string } from 'yup'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import { ConflictHttpError } from '../../../util/errors/ConflictHttpError'; +import type { AccountStore } from '../account/util/AccountStore'; +import { addLoginEntry, getRequiredAccount } from '../account/util/AccountUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import type { JsonView } from '../JsonView'; +import { parseSchema, validateWithError } from '../YupUtil'; +import type { PasswordIdRoute } from './util/PasswordIdRoute'; +import { PASSWORD_METHOD } from './util/PasswordStore'; +import type { PasswordStore } from './util/PasswordStore'; + +type OutType = { resource: string }; + +const inSchema = object({ + // Store e-mail addresses in lower case + email: string().trim().email().lowercase() + .required(), + password: string().trim().min(1).required(), +}); + +/** + * Handles the creation of email/password login combinations for an account. + */ +export class CreatePasswordHandler extends JsonInteractionHandler implements JsonView { + protected readonly logger = getLoggerFor(this); + + private readonly passwordStore: PasswordStore; + private readonly accountStore: AccountStore; + private readonly passwordRoute: PasswordIdRoute; + + public constructor(passwordStore: PasswordStore, accountStore: AccountStore, passwordRoute: PasswordIdRoute) { + super(); + this.passwordStore = passwordStore; + this.accountStore = accountStore; + this.passwordRoute = passwordRoute; + } + + public async getView(): Promise { + return { json: parseSchema(inSchema) }; + } + + public async handle({ accountId, json }: JsonInteractionHandlerInput): Promise> { + const account = await getRequiredAccount(this.accountStore, accountId); + + // Email will be in lowercase + const { email, password } = await validateWithError(inSchema, json); + + if (account.logins[PASSWORD_METHOD]?.[email]) { + throw new ConflictHttpError('This account already has a login method for this e-mail address.'); + } + + const resource = this.passwordRoute.getPath({ accountId: account.id, passwordId: encodeURIComponent(email) }); + + // We need to create the password entry first before trying to add it to the account, + // otherwise it might be impossible to remove it from the account again since + // you can't remove a login method from an account if it is the last one. + await this.passwordStore.create(email, account.id, password); + + // If we ever want to add email verification this would have to be checked separately + await this.passwordStore.confirmVerification(email); + + try { + addLoginEntry(account, PASSWORD_METHOD, email, resource); + await this.accountStore.update(account); + } catch (error: unknown) { + this.logger.warn(`Error while updating account ${account.id}, reverting operation.`); + await this.passwordStore.delete(email); + throw error; + } + + return { json: { resource }}; + } +} diff --git a/src/identity/interaction/password/DeletePasswordHandler.ts b/src/identity/interaction/password/DeletePasswordHandler.ts new file mode 100644 index 000000000..955f690d5 --- /dev/null +++ b/src/identity/interaction/password/DeletePasswordHandler.ts @@ -0,0 +1,44 @@ +import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError'; +import type { EmptyObject } from '../../../util/map/MapUtil'; +import type { AccountStore } from '../account/util/AccountStore'; +import { ensureResource, getRequiredAccount, safeUpdate } from '../account/util/AccountUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import { PASSWORD_METHOD } from './util/PasswordStore'; +import type { PasswordStore } from './util/PasswordStore'; + +/** + * Handles the deletion of a password login method. + */ +export class DeletePasswordHandler extends JsonInteractionHandler { + private readonly accountStore: AccountStore; + private readonly passwordStore: PasswordStore; + + public constructor(accountStore: AccountStore, passwordStore: PasswordStore) { + super(); + this.accountStore = accountStore; + this.passwordStore = passwordStore; + } + + public async handle({ target, accountId }: JsonInteractionHandlerInput): Promise> { + const account = await getRequiredAccount(this.accountStore, accountId); + + const passwordLogins = account.logins[PASSWORD_METHOD]; + if (!passwordLogins) { + throw new NotFoundHttpError(); + } + + const email = ensureResource(passwordLogins, target.path); + + // This needs to happen first since this checks that there is at least 1 login method + delete passwordLogins[email]; + + // Delete the password data and revert if something goes wrong + await safeUpdate(account, + this.accountStore, + (): Promise => this.passwordStore.delete(email)); + + return { json: {}}; + } +} diff --git a/src/identity/interaction/password/ForgotPasswordHandler.ts b/src/identity/interaction/password/ForgotPasswordHandler.ts new file mode 100644 index 000000000..237278057 --- /dev/null +++ b/src/identity/interaction/password/ForgotPasswordHandler.ts @@ -0,0 +1,115 @@ +import { object, string } from 'yup'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import { createErrorMessage } from '../../../util/errors/ErrorUtil'; +import type { TemplateEngine } from '../../../util/templates/TemplateEngine'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { JsonView } from '../JsonView'; +import type { InteractionRoute } from '../routing/InteractionRoute'; +import { parseSchema, validateWithError } from '../YupUtil'; +import type { EmailSender } from './util/EmailSender'; +import type { ForgotPasswordStore } from './util/ForgotPasswordStore'; +import type { PasswordStore } from './util/PasswordStore'; + +const inSchema = object({ + email: string().trim().email().required(), +}); + +export interface ForgotPasswordHandlerArgs { + /** + * Store containing the password login information. + */ + passwordStore: PasswordStore; + /** + * Store containing the forgot password records. + */ + forgotPasswordStore: ForgotPasswordStore; + /** + * Template engine that will be used to generate the email body. + */ + templateEngine: TemplateEngine<{ resetLink: string }>; + /** + * Sender to send the actual email. + */ + emailSender: EmailSender; + /** + * Route used to generate the reset link for the user. + */ + resetRoute: InteractionRoute; +} + +type OutType = { email: string }; + +/** + * Responsible for the case where a user forgot their password and asks for a reset. + * Will send out the necessary mail if the email address is known. + * The JSON response will always be the same to prevent leaking which email addresses are stored. + */ +export class ForgotPasswordHandler extends JsonInteractionHandler implements JsonView { + protected readonly logger = getLoggerFor(this); + + private readonly passwordStore: PasswordStore; + private readonly forgotPasswordStore: ForgotPasswordStore; + private readonly templateEngine: TemplateEngine<{ resetLink: string }>; + private readonly emailSender: EmailSender; + private readonly resetRoute: InteractionRoute; + + public constructor(args: ForgotPasswordHandlerArgs) { + super(); + this.passwordStore = args.passwordStore; + this.forgotPasswordStore = args.forgotPasswordStore; + this.templateEngine = args.templateEngine; + this.emailSender = args.emailSender; + this.resetRoute = args.resetRoute; + } + + public async getView(): Promise { + return { json: parseSchema(inSchema) }; + } + + /** + * Generates a record to reset the password for the given email address and then mails it. + * In case there is no account, no error wil be thrown for privacy reasons. + * Nothing will happen instead. + */ + public async handle({ json }: JsonInteractionHandlerInput): Promise> { + const { email } = await validateWithError(inSchema, json); + + const accountId = await this.passwordStore.get(email); + + if (accountId) { + try { + const recordId = await this.forgotPasswordStore.generate(email); + await this.sendResetMail(recordId, email); + } catch (error: unknown) { + // This error can not be thrown for privacy reasons. + // If there always is an error, because there is a problem with the mail server for example, + // errors would only be thrown for registered accounts. + // Although we do also leak this information when an account tries to register an email address, + // so this might be removed in the future. + this.logger.error(`Problem sending a recovery mail: ${createErrorMessage(error)}`); + } + } else { + // Don't emit an error for privacy reasons + this.logger.warn(`Password reset request for unknown email ${email}`); + } + + return { json: { email }}; + } + + /** + * Generates the link necessary for resetting the password and mails it to the given email address. + */ + private async sendResetMail(recordId: string, email: string): Promise { + this.logger.info(`Sending password reset to ${email}`); + const resetLink = `${this.resetRoute.getPath()}?rid=${encodeURIComponent(recordId)}`; + const renderedEmail = await this.templateEngine.handleSafe({ contents: { resetLink }}); + await this.emailSender.handleSafe({ + recipient: email, + subject: 'Reset your password', + text: `To reset your password, go to this link: ${resetLink}`, + html: renderedEmail, + }); + } +} diff --git a/src/identity/interaction/password/PasswordLoginHandler.ts b/src/identity/interaction/password/PasswordLoginHandler.ts new file mode 100644 index 000000000..ec497b319 --- /dev/null +++ b/src/identity/interaction/password/PasswordLoginHandler.ts @@ -0,0 +1,52 @@ +import { boolean, object, string } from 'yup'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import type { AccountIdRoute } from '../account/AccountIdRoute'; +import type { AccountStore } from '../account/util/AccountStore'; +import type { CookieStore } from '../account/util/CookieStore'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import type { JsonView } from '../JsonView'; +import type { LoginOutputType } from '../login/ResolveLoginHandler'; +import { ResolveLoginHandler } from '../login/ResolveLoginHandler'; +import { parseSchema, validateWithError } from '../YupUtil'; +import type { PasswordStore } from './util/PasswordStore'; + +const inSchema = object({ + email: string().trim().email().required(), + password: string().trim().required(), + remember: boolean().default(false), +}); + +export interface PasswordLoginHandlerArgs { + accountStore: AccountStore; + passwordStore: PasswordStore; + cookieStore: CookieStore; + accountRoute: AccountIdRoute; +} + +/** + * Handles the submission of the Login Form and logs the user in. + */ +export class PasswordLoginHandler extends ResolveLoginHandler implements JsonView { + protected readonly logger = getLoggerFor(this); + + private readonly passwordStore: PasswordStore; + + public constructor(args: PasswordLoginHandlerArgs) { + super(args.accountStore, args.cookieStore, args.accountRoute); + this.passwordStore = args.passwordStore; + } + + public async getView(): Promise { + return { json: parseSchema(inSchema) }; + } + + public async login({ json }: JsonInteractionHandlerInput): Promise> { + const { email, password, remember } = await validateWithError(inSchema, json); + // Try to log in, will error if email/password combination is invalid + const accountId = await this.passwordStore.authenticate(email, password); + this.logger.debug(`Logging in user ${email}`); + + return { json: { accountId, remember }}; + } +} diff --git a/src/identity/interaction/password/ResetPasswordHandler.ts b/src/identity/interaction/password/ResetPasswordHandler.ts new file mode 100644 index 000000000..73428333e --- /dev/null +++ b/src/identity/interaction/password/ResetPasswordHandler.ts @@ -0,0 +1,62 @@ +import { object, string } from 'yup'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; +import type { EmptyObject } from '../../../util/map/MapUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { JsonView } from '../JsonView'; +import { parseSchema, validateWithError } from '../YupUtil'; +import type { ForgotPasswordStore } from './util/ForgotPasswordStore'; +import type { PasswordStore } from './util/PasswordStore'; + +const inSchema = object({ + recordId: string().trim().min(1).required(), + password: string().trim().required(), +}); + +/** + * Resets a password if a valid `recordId` is provided, + * which should have been generated by a different handler. + */ +export class ResetPasswordHandler extends JsonInteractionHandler implements JsonView { + protected readonly logger = getLoggerFor(this); + + private readonly passwordStore: PasswordStore; + private readonly forgotPasswordStore: ForgotPasswordStore; + + public constructor(passwordStore: PasswordStore, forgotPasswordStore: ForgotPasswordStore) { + super(); + this.passwordStore = passwordStore; + this.forgotPasswordStore = forgotPasswordStore; + } + + public async getView(): Promise { + return { json: parseSchema(inSchema) }; + } + + public async handle({ json }: JsonInteractionHandlerInput): Promise> { + // Validate input data + const { password, recordId } = await validateWithError(inSchema, json); + + await this.resetPassword(recordId, password); + return { json: {}}; + } + + /** + * Resets the password for the account associated with the given recordId. + */ + private async resetPassword(recordId: string, newPassword: string): Promise { + const email = await this.forgotPasswordStore.get(recordId); + + if (!email) { + this.logger.warn(`Trying to use invalid reset URL with record ID ${recordId}`); + throw new BadRequestHttpError('This reset password link is no longer valid.'); + } + + await this.passwordStore.update(email, newPassword); + await this.forgotPasswordStore.delete(recordId); + + this.logger.debug(`Resetting password for user ${email}`); + } +} diff --git a/src/identity/interaction/password/UpdatePasswordHandler.ts b/src/identity/interaction/password/UpdatePasswordHandler.ts new file mode 100644 index 000000000..5227371f1 --- /dev/null +++ b/src/identity/interaction/password/UpdatePasswordHandler.ts @@ -0,0 +1,59 @@ +import { object, string } from 'yup'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; +import type { EmptyObject } from '../../../util/map/MapUtil'; +import type { AccountStore } from '../account/util/AccountStore'; +import { ensureResource, getRequiredAccount } from '../account/util/AccountUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { JsonView } from '../JsonView'; +import { parseSchema, validateWithError } from '../YupUtil'; +import { PASSWORD_METHOD } from './util/PasswordStore'; +import type { PasswordStore } from './util/PasswordStore'; + +const inSchema = object({ + oldPassword: string().trim().min(1).required(), + newPassword: string().trim().min(1).required(), +}); + +/** + * Allows the password of a login to be updated. + */ +export class UpdatePasswordHandler extends JsonInteractionHandler implements JsonView { + private readonly logger = getLoggerFor(this); + + private readonly accountStore: AccountStore; + private readonly passwordStore: PasswordStore; + + public constructor(accountStore: AccountStore, passwordStore: PasswordStore) { + super(); + this.accountStore = accountStore; + this.passwordStore = passwordStore; + } + + public async getView(): Promise { + return { json: parseSchema(inSchema) }; + } + + public async handle(input: JsonInteractionHandlerInput): Promise> { + const { target, accountId, json } = input; + const account = await getRequiredAccount(this.accountStore, accountId); + + const email = ensureResource(account.logins[PASSWORD_METHOD], target.path); + + const { oldPassword, newPassword } = await validateWithError(inSchema, json); + + // Make sure the old password is correct + try { + await this.passwordStore.authenticate(email, oldPassword); + } catch { + this.logger.warn(`Invalid password when trying to reset for email ${email}`); + throw new BadRequestHttpError('Old password is invalid.'); + } + + await this.passwordStore.update(email, newPassword); + + return { json: {}}; + } +} diff --git a/src/identity/interaction/email-password/util/BaseEmailSender.ts b/src/identity/interaction/password/util/BaseEmailSender.ts similarity index 84% rename from src/identity/interaction/email-password/util/BaseEmailSender.ts rename to src/identity/interaction/password/util/BaseEmailSender.ts index 6c1c56f2b..066f837ee 100644 --- a/src/identity/interaction/email-password/util/BaseEmailSender.ts +++ b/src/identity/interaction/password/util/BaseEmailSender.ts @@ -1,5 +1,6 @@ import { createTransport } from 'nodemailer'; import type Mail from 'nodemailer/lib/mailer'; +import { getLoggerFor } from '../../../../logging/LogUtil'; import { EmailSender } from './EmailSender'; import type { EmailArgs } from './EmailSender'; @@ -19,6 +20,8 @@ export interface EmailSenderArgs { * Sends e-mails using nodemailer. */ export class BaseEmailSender extends EmailSender { + private readonly logger = getLoggerFor(this); + private readonly mailTransporter: Mail; private readonly senderName: string; @@ -36,5 +39,6 @@ export class BaseEmailSender extends EmailSender { text, html, }); + this.logger.debug(`Sending recovery mail to ${recipient}`); } } diff --git a/src/identity/interaction/password/util/BaseForgotPasswordStore.ts b/src/identity/interaction/password/util/BaseForgotPasswordStore.ts new file mode 100644 index 000000000..cb27865b0 --- /dev/null +++ b/src/identity/interaction/password/util/BaseForgotPasswordStore.ts @@ -0,0 +1,30 @@ +import { v4 } from 'uuid'; +import type { ExpiringStorage } from '../../../../storage/keyvalue/ExpiringStorage'; +import type { ForgotPasswordStore } from './ForgotPasswordStore'; + +/** + * A {@link ForgotPasswordStore} using an {@link ExpiringStorage} to hold the necessary records. + */ +export class BaseForgotPasswordStore implements ForgotPasswordStore { + private readonly storage: ExpiringStorage; + private readonly ttl: number; + + public constructor(storage: ExpiringStorage, ttl = 15) { + this.storage = storage; + this.ttl = ttl * 60 * 1000; + } + + public async generate(email: string): Promise { + const recordId = v4(); + await this.storage.set(recordId, email, this.ttl); + return recordId; + } + + public async get(recordId: string): Promise { + return this.storage.get(recordId); + } + + public async delete(recordId: string): Promise { + return this.storage.delete(recordId); + } +} diff --git a/src/identity/interaction/password/util/BasePasswordStore.ts b/src/identity/interaction/password/util/BasePasswordStore.ts new file mode 100644 index 000000000..f5d0579c1 --- /dev/null +++ b/src/identity/interaction/password/util/BasePasswordStore.ts @@ -0,0 +1,103 @@ +import { hash, compare } from 'bcryptjs'; +import { getLoggerFor } from '../../../../logging/LogUtil'; +import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; +import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; +import { ForbiddenHttpError } from '../../../../util/errors/ForbiddenHttpError'; +import type { PasswordStore } from './PasswordStore'; + +/** + * A payload to persist a user account + */ +export interface LoginPayload { + accountId: string; + password: string; + verified: boolean; +} + +/** + * A {@link PasswordStore} that uses a {@link KeyValueStorage} to store the entries. + * Passwords are hashed and salted. + * Default `saltRounds` is 10. + */ +export class BasePasswordStore implements PasswordStore { + private readonly logger = getLoggerFor(this); + + private readonly storage: KeyValueStorage; + private readonly saltRounds: number; + + public constructor(storage: KeyValueStorage, saltRounds = 10) { + this.storage = storage; + this.saltRounds = saltRounds; + } + + /** + * Helper function that converts the given e-mail to a resource identifier + * and retrieves the login data from the internal storage. + * + * Will error if `checkExistence` is true and there is no login data for that email. + */ + private async getLoginPayload(email: string, checkExistence: true): Promise<{ key: string; payload: LoginPayload }>; + private async getLoginPayload(email: string, checkExistence: false): Promise<{ key: string; payload?: LoginPayload }>; + private async getLoginPayload(email: string, checkExistence: boolean): + Promise<{ key: string; payload?: LoginPayload }> { + const key = encodeURIComponent(email.toLowerCase()); + const payload = await this.storage.get(key); + if (checkExistence && !payload) { + this.logger.warn(`Trying to get account info for unknown email ${email}`); + throw new ForbiddenHttpError('Login does not exist.'); + } + return { key, payload }; + } + + public async get(email: string): Promise { + const { payload } = await this.getLoginPayload(email, false); + return payload?.accountId; + } + + public async authenticate(email: string, password: string): Promise { + const { payload } = await this.getLoginPayload(email, true); + if (!payload.verified) { + this.logger.warn(`Trying to get account info for unverified email ${email}`); + throw new ForbiddenHttpError('Login still needs to be verified.'); + } + if (!await compare(password, payload.password)) { + this.logger.warn(`Incorrect password for email ${email}`); + throw new ForbiddenHttpError('Incorrect password.'); + } + return payload.accountId; + } + + public async create(email: string, accountId: string, password: string): Promise { + const { key, payload } = await this.getLoginPayload(email, false); + if (payload) { + this.logger.warn(`Trying to create duplicate login for email ${email}`); + throw new BadRequestHttpError('There already is a login for this e-mail address.'); + } + await this.storage.set(key, { + accountId, + password: await hash(password, this.saltRounds), + verified: false, + }); + } + + public async confirmVerification(email: string): Promise { + const { key, payload } = await this.getLoginPayload(email, true); + payload.verified = true; + await this.storage.set(key, payload); + } + + public async update(email: string, password: string): Promise { + const { key, payload } = await this.getLoginPayload(email, true); + payload.password = await hash(password, this.saltRounds); + await this.storage.set(key, payload); + } + + public async delete(email: string): Promise { + const { key, payload } = await this.getLoginPayload(email, false); + const exists = Boolean(payload); + if (exists) { + await this.storage.delete(key); + } + return exists; + } +} diff --git a/src/identity/interaction/email-password/util/EmailSender.ts b/src/identity/interaction/password/util/EmailSender.ts similarity index 100% rename from src/identity/interaction/email-password/util/EmailSender.ts rename to src/identity/interaction/password/util/EmailSender.ts diff --git a/src/identity/interaction/password/util/ForgotPasswordStore.ts b/src/identity/interaction/password/util/ForgotPasswordStore.ts new file mode 100644 index 000000000..6bbdde0a0 --- /dev/null +++ b/src/identity/interaction/password/util/ForgotPasswordStore.ts @@ -0,0 +1,27 @@ +/** + * Responsible for storing the records that are used when a user forgets their password. + */ +export interface ForgotPasswordStore { + /** + * Creates a Forgot Password Confirmation Record. This will be to remember that + * a user has made a request to reset a password. Throws an error if the email doesn't + * exist. + * @param email - The user's email. + * @returns The record id. This should be included in the reset password link. + */ + generate: (email: string) => Promise; + + /** + * Gets the email associated with the forgot password confirmation record + * or undefined if it's not present. + * @param recordId - The record id retrieved from the link. + * @returns The user's email. + */ + get: (recordId: string) => Promise; + + /** + * Deletes the Forgot Password Confirmation Record. + * @param recordId - The record id of the forgot password confirmation record. + */ + delete: (recordId: string) => Promise; +} diff --git a/src/identity/interaction/password/util/PasswordIdRoute.ts b/src/identity/interaction/password/util/PasswordIdRoute.ts new file mode 100644 index 000000000..1f63f73dc --- /dev/null +++ b/src/identity/interaction/password/util/PasswordIdRoute.ts @@ -0,0 +1,19 @@ +import type { AccountIdKey, AccountIdRoute } from '../../account/AccountIdRoute'; +import { IdInteractionRoute } from '../../routing/IdInteractionRoute'; +import type { ExtendedRoute } from '../../routing/InteractionRoute'; + +export type PasswordIdKey = 'passwordId'; + +/** + * An {@link AccountIdRoute} that also includes a password login identifier. + */ +export type PasswordIdRoute = ExtendedRoute; + +/** + * Implementation of an {@link PasswordIdRoute} that adds the identifier relative to a base {@link AccountIdRoute}. + */ +export class BasePasswordIdRoute extends IdInteractionRoute { + public constructor(base: AccountIdRoute) { + super(base, 'passwordId'); + } +} diff --git a/src/identity/interaction/password/util/PasswordStore.ts b/src/identity/interaction/password/util/PasswordStore.ts new file mode 100644 index 000000000..426057020 --- /dev/null +++ b/src/identity/interaction/password/util/PasswordStore.ts @@ -0,0 +1,54 @@ +/** + * The constant used to identify email/password based login combinations in the map of logins an account has. + */ +export const PASSWORD_METHOD = 'password'; + +/** + * Responsible for storing everything related to email/password based login combinations. + */ +export interface PasswordStore { + /** + * Finds the Account ID linked to this email address. + * @param email - The email address of which to find the account. + * @returns The relevant Account ID or `undefined` if there is no match. + */ + get: (email: string) => Promise; + + /** + * Authenticate if the email and password are correct and return the Account ID if it is. + * Throw an error if it is not. + * @param email - The user's email. + * @param password - This user's password. + * @returns The user's Account ID. + */ + authenticate: (email: string, password: string) => Promise; + + /** + * Stores a new login entry for this account. + * @param email - Account email. + * @param accountId - Account ID. + * @param password - Account password. + */ + create: (email: string, accountId: string, password: string) => Promise; + + /** + * Confirms that the e-mail address has been verified. This can be used with, for example, email verification. + * The login can only be used after it is verified. + * In case verification is not required, this should be called immediately after the `create` call. + * @param email - The account email. + */ + confirmVerification: (email: string) => Promise; + + /** + * Changes the password. + * @param email - The user's email. + * @param password - The user's password. + */ + update: (email: string, password: string) => Promise; + + /** + * Delete the login entry of this email address. + * @param email - The user's email. + */ + delete: (email: string) => Promise; +} diff --git a/src/identity/interaction/pod/CreatePodHandler.ts b/src/identity/interaction/pod/CreatePodHandler.ts new file mode 100644 index 000000000..3f17c0efc --- /dev/null +++ b/src/identity/interaction/pod/CreatePodHandler.ts @@ -0,0 +1,165 @@ +import type { StringSchema } from 'yup'; +import { object, string } from 'yup'; +import type { ResourceIdentifier } from '../../../http/representation/ResourceIdentifier'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import type { IdentifierGenerator } from '../../../pods/generate/IdentifierGenerator'; +import type { PodSettings } from '../../../pods/settings/PodSettings'; +import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; +import { joinUrl } from '../../../util/PathUtil'; +import type { AccountStore } from '../account/util/AccountStore'; +import { getRequiredAccount } from '../account/util/AccountUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import type { JsonView } from '../JsonView'; +import type { WebIdStore } from '../webid/util/WebIdStore'; +import { parseSchema, URL_SCHEMA, validateWithError } from '../YupUtil'; +import type { PodStore } from './util/PodStore'; + +const inSchema = object({ + name: string().trim().min(1).optional(), + settings: object({ + webId: URL_SCHEMA, + }).optional(), +}); + +export interface CreatePodHandlerArgs { + /** + * Base URL of the server. + * Used to potentially set the `solid:oidcIssuer` triple + * and/or the pod URL if it is a root pod. + */ + baseUrl: string; + /** + * Generates the base URL of the pod based on the input `name`. + */ + identifierGenerator: IdentifierGenerator; + /** + * The path of where the WebID will be generated by the template, relative to the pod URL. + */ + relativeWebIdPath: string; + /** + * Account data store. + */ + accountStore: AccountStore; + /** + * WebID data store. + */ + webIdStore: WebIdStore; + /** + * Pod data store. + */ + podStore: PodStore; + /** + * Whether it is allowed to generate a pod in the root of the server. + */ + allowRoot: boolean; +} + +type OutType = { + pod: string; + podResource: string; + webId: string; + webIdResource?: string; +}; + +/** + * Handles the creation of pods. + * Will call the stored {@link PodStore} with the settings found in the input JSON. + */ +export class CreatePodHandler extends JsonInteractionHandler implements JsonView { + private readonly logger = getLoggerFor(this); + + private readonly baseUrl: string; + private readonly identifierGenerator: IdentifierGenerator; + private readonly relativeWebIdPath: string; + private readonly accountStore: AccountStore; + private readonly webIdStore: WebIdStore; + private readonly podStore: PodStore; + + private readonly inSchema: typeof inSchema; + + public constructor(args: CreatePodHandlerArgs) { + super(); + this.baseUrl = args.baseUrl; + this.identifierGenerator = args.identifierGenerator; + this.relativeWebIdPath = args.relativeWebIdPath; + this.accountStore = args.accountStore; + this.webIdStore = args.webIdStore; + this.podStore = args.podStore; + + this.inSchema = inSchema.clone(); + + if (!args.allowRoot) { + // Casting is necessary to prevent errors + this.inSchema.fields.name = (this.inSchema.fields.name as StringSchema).required(); + } + } + + public async getView(): Promise { + return { json: parseSchema(this.inSchema) }; + } + + public async handle({ json, accountId }: JsonInteractionHandlerInput): Promise> { + const account = await getRequiredAccount(this.accountStore, accountId); + + // In case the class was not initialized with allowRoot: false, missing name values will result in an error + const { name, settings } = await validateWithError(inSchema, json); + + const baseIdentifier = this.generateBaseIdentifier(name); + // Either the input WebID or the one generated in the pod + const webId = settings?.webId ?? joinUrl(baseIdentifier.path, this.relativeWebIdPath); + const linkWebId = !settings?.webId; + + const podSettings: PodSettings = { + ...settings, + base: baseIdentifier, + webId, + }; + + // Link the WebID to the account immediately if no WebID was provided. + // This WebID will be necessary anyway to access the data in the pod, + // so might as well link it to the account immediately. + let webIdResource: string | undefined; + if (linkWebId) { + // It is important that this check happens here. + // Otherwise, if the account already has this WebID link, + // this link would be deleted if pod creation fails, + // since we clean up the WebID link again afterwards. + // Current implementation of the {@link WebIdStore} also has this check but better safe than sorry. + if (account.webIds[webId]) { + this.logger.warn('Trying to create pod which would generate a WebID that already is linked to this account'); + throw new BadRequestHttpError(`${webId} is already registered to this account.`); + } + + webIdResource = await this.webIdStore.add(webId, account); + // Need to have the necessary `solid:oidcIssuer` triple if the WebID is linked + podSettings.oidcIssuer = this.baseUrl; + } + + // Create the pod + let podResource: string; + try { + podResource = await this.podStore.create(account, podSettings, !name); + } catch (error: unknown) { + // Undo the WebID linking if pod creation fails + if (linkWebId) { + // There was an error while trying to update the account above, + // so we shouldn't assume the account object we have is still valid. + const currentAccount = await getRequiredAccount(this.accountStore, accountId); + await this.webIdStore.delete(webId, currentAccount); + } + + throw error; + } + + return { json: { pod: baseIdentifier.path, webId, podResource, webIdResource }}; + } + + private generateBaseIdentifier(name?: string): ResourceIdentifier { + if (name) { + return this.identifierGenerator.generate(name); + } + return { path: this.baseUrl }; + } +} diff --git a/src/identity/interaction/pod/PodIdRoute.ts b/src/identity/interaction/pod/PodIdRoute.ts new file mode 100644 index 000000000..41e5b94d9 --- /dev/null +++ b/src/identity/interaction/pod/PodIdRoute.ts @@ -0,0 +1,19 @@ +import type { AccountIdKey, AccountIdRoute } from '../account/AccountIdRoute'; +import { IdInteractionRoute } from '../routing/IdInteractionRoute'; +import type { ExtendedRoute } from '../routing/InteractionRoute'; + +export type PodIdKey = 'podId'; + +/** + * An {@link AccountIdRoute} that also includes a pod identifier. + */ +export type PodIdRoute = ExtendedRoute; + +/** + * Implementation of an {@link PodIdRoute} that adds the identifier relative to a base {@link AccountIdRoute}. + */ +export class BasePodIdRoute extends IdInteractionRoute { + public constructor(base: AccountIdRoute) { + super(base, 'podId'); + } +} diff --git a/src/identity/interaction/pod/util/BasePodStore.ts b/src/identity/interaction/pod/util/BasePodStore.ts new file mode 100644 index 000000000..f225983d6 --- /dev/null +++ b/src/identity/interaction/pod/util/BasePodStore.ts @@ -0,0 +1,48 @@ +import { createHash } from 'crypto'; +import { getLoggerFor } from '../../../../logging/LogUtil'; +import type { PodManager } from '../../../../pods/PodManager'; +import type { PodSettings } from '../../../../pods/settings/PodSettings'; +import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; +import { createErrorMessage } from '../../../../util/errors/ErrorUtil'; +import type { Account } from '../../account/util/Account'; +import type { AccountStore } from '../../account/util/AccountStore'; +import { safeUpdate } from '../../account/util/AccountUtil'; +import type { PodIdRoute } from '../PodIdRoute'; +import type { PodStore } from './PodStore'; + +/** + * A {@link PodStore} implementation using a {@link PodManager} to create pods. + */ +export class BasePodStore implements PodStore { + private readonly logger = getLoggerFor(this); + + private readonly accountStore: AccountStore; + private readonly podRoute: PodIdRoute; + private readonly manager: PodManager; + + public constructor(accountStore: AccountStore, podRoute: PodIdRoute, manager: PodManager) { + this.accountStore = accountStore; + this.podRoute = podRoute; + this.manager = manager; + } + + public async create(account: Account, settings: PodSettings, overwrite: boolean): Promise { + const base = settings.base.path; + // The unique identifier of the pod-account link + const podId = createHash('sha256').update(base).digest('hex'); + const resource = this.podRoute.getPath({ accountId: account.id, podId }); + account.pods[base] = resource; + + try { + await safeUpdate(account, + this.accountStore, + (): Promise => this.manager.createPod(settings, overwrite)); + } catch (error: unknown) { + this.logger.warn(`Pod creation failed for account ${account.id}: ${createErrorMessage(error)}`); + throw new BadRequestHttpError(`Pod creation failed: ${createErrorMessage(error)}`); + } + this.logger.debug(`Created pod ${settings.name} for account ${account.id}`); + + return resource; + } +} diff --git a/src/identity/interaction/pod/util/PodStore.ts b/src/identity/interaction/pod/util/PodStore.ts new file mode 100644 index 000000000..d516ffe3e --- /dev/null +++ b/src/identity/interaction/pod/util/PodStore.ts @@ -0,0 +1,18 @@ +import type { PodSettings } from '../../../../pods/settings/PodSettings'; +import type { Account } from '../../account/util/Account'; + +/** + * Can be used to create new pods. + */ +export interface PodStore { + /** + * Creates a new pod and updates the account accordingly. + * + * @param account - Account to create a pod for. Object will be updated in place. + * @param settings - Settings to create a pod with. + * @param overwrite - If the pod is allowed to overwrite existing data. + * + * @returns The resource corresponding to the created pod for this account. + */ + create: (account: Account, settings: PodSettings, overwrite: boolean) => Promise; +} diff --git a/src/identity/interaction/routing/AbsolutePathInteractionRoute.ts b/src/identity/interaction/routing/AbsolutePathInteractionRoute.ts index 7a7e0d897..20adfed51 100644 --- a/src/identity/interaction/routing/AbsolutePathInteractionRoute.ts +++ b/src/identity/interaction/routing/AbsolutePathInteractionRoute.ts @@ -1,16 +1,27 @@ +import type { EmptyObject } from '../../../util/map/MapUtil'; +import { ensureTrailingSlash } from '../../../util/PathUtil'; import type { InteractionRoute } from './InteractionRoute'; /** - * A route that returns the input string as path. + * A route that stores a single absolute path. */ export class AbsolutePathInteractionRoute implements InteractionRoute { private readonly path: string; - public constructor(path: string) { + public constructor(path: string, ensureSlash = true) { this.path = path; + if (ensureSlash) { + this.path = ensureTrailingSlash(this.path); + } } public getPath(): string { return this.path; } + + public matchPath(path: string): EmptyObject | undefined { + if (path === this.path) { + return {}; + } + } } diff --git a/src/identity/interaction/routing/AuthorizedRouteHandler.ts b/src/identity/interaction/routing/AuthorizedRouteHandler.ts new file mode 100644 index 000000000..3e8820060 --- /dev/null +++ b/src/identity/interaction/routing/AuthorizedRouteHandler.ts @@ -0,0 +1,43 @@ +import { getLoggerFor } from '../../../logging/LogUtil'; +import { ForbiddenHttpError } from '../../../util/errors/ForbiddenHttpError'; +import { UnauthorizedHttpError } from '../../../util/errors/UnauthorizedHttpError'; +import type { AccountIdRoute } from '../account/AccountIdRoute'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { + JsonInteractionHandler, + JsonInteractionHandlerInput, + +} from '../JsonInteractionHandler'; +import { InteractionRouteHandler } from './InteractionRouteHandler'; + +/** + * An {@link InteractionRouteHandler} specifically for an {@link AccountIdRoute}. + * If there is no account ID, implying the user is not logged in, + * an {@link UnauthorizedHttpError} will be thrown. + * If there is an account ID, but it does not match the one in target resource, + * a {@link ForbiddenHttpError} will be thrown. + */ +export class AuthorizedRouteHandler extends InteractionRouteHandler { + private readonly logger = getLoggerFor(this); + + public constructor(route: AccountIdRoute, source: JsonInteractionHandler) { + super(route, source); + } + + public async handle(input: JsonInteractionHandlerInput): Promise { + const { target, accountId } = input; + + if (!accountId) { + this.logger.warn(`Trying to access ${target.path} without authorization`); + throw new UnauthorizedHttpError(); + } + + const match = this.route.matchPath(target.path)!; + if (match.accountId !== accountId) { + this.logger.warn(`Trying to access ${target.path} with wrong authorization: ${accountId}`); + throw new ForbiddenHttpError(); + } + + return this.source.handle(input); + } +} diff --git a/src/identity/interaction/routing/IdInteractionRoute.ts b/src/identity/interaction/routing/IdInteractionRoute.ts new file mode 100644 index 000000000..595c96257 --- /dev/null +++ b/src/identity/interaction/routing/IdInteractionRoute.ts @@ -0,0 +1,49 @@ +import { InternalServerError } from '../../../util/errors/InternalServerError'; +import { ensureTrailingSlash, joinUrl } from '../../../util/PathUtil'; +import type { InteractionRoute } from './InteractionRoute'; + +/** + * An {@link InteractionRoute} for routes that have a dynamic identifier in their path. + */ +export class IdInteractionRoute implements InteractionRoute { + private readonly base: InteractionRoute; + private readonly idName: TId; + private readonly ensureSlash: boolean; + + public constructor(base: InteractionRoute, idName: TId, ensureSlash = true) { + this.base = base; + this.idName = idName; + this.ensureSlash = ensureSlash; + } + + public getPath(parameters?: Record): string { + const id = parameters?.[this.idName]; + if (!id) { + throw new InternalServerError(`Missing ${this.idName} from getPath call. This implies a misconfigured path.`); + } + + const path = this.base.getPath(parameters); + return joinUrl(path, this.ensureSlash ? ensureTrailingSlash(id) : id); + } + + public matchPath(path: string): Record | undefined { + const match = /(.*\/)([^/]+)\/$/u.exec(path); + + if (!match) { + return; + } + + const id = match[2]; + const head = match[1]; + + const baseParameters = this.base.matchPath(head); + + if (!baseParameters) { + return; + } + + // Cast needed because TS always assumes type is { [x: string]: string; } when using [] like this + // https://github.com/microsoft/TypeScript/issues/13948 + return { ...baseParameters, [this.idName]: id } as Record; + } +} diff --git a/src/identity/interaction/routing/InteractionRoute.ts b/src/identity/interaction/routing/InteractionRoute.ts index 3b0caccff..61d520805 100644 --- a/src/identity/interaction/routing/InteractionRoute.ts +++ b/src/identity/interaction/routing/InteractionRoute.ts @@ -1,9 +1,39 @@ /** - * An object with a specific path. + * The parameters supported for the given route. */ -export interface InteractionRoute { +export type RouteParameter> = + TRoute extends InteractionRoute ? TParam : never; + +/** + * A route that adds a parameter to an existing route type. + */ +export type ExtendedRoute, TParam extends string> = + InteractionRoute | TParam>; + +/** + * Routes are used to handle the pathing for API calls. + * + * They can have dynamic values in the paths they support. + * Typings are used to indicate the keys used to indicate what the corresponding values are. + */ +export interface InteractionRoute { /** - * @returns The absolute path of this route. + * Returns the path that is the result of having the specified values for the dynamic parameters. + * + * Will throw an error in case the input `parameters` object is missing one of the expected keys. + * + * @param parameters - Values for the dynamic parameters. */ - getPath: () => string; + getPath: (parameters?: Record) => string; + + /** + * Checks if the provided path matches the route (pattern). + * + * The result will be `undefined` if there is no match. + * + * If there is a match the result object will have the corresponding values for all the parameters. + * + * @param path - The path to verify. + */ + matchPath: (path: string) => Record | undefined; } diff --git a/src/identity/interaction/routing/InteractionRouteHandler.ts b/src/identity/interaction/routing/InteractionRouteHandler.ts index 22573524d..45e204484 100644 --- a/src/identity/interaction/routing/InteractionRouteHandler.ts +++ b/src/identity/interaction/routing/InteractionRouteHandler.ts @@ -1,35 +1,35 @@ -import type { Representation } from '../../../http/representation/Representation'; import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError'; -import type { InteractionHandlerInput } from '../InteractionHandler'; -import { InteractionHandler } from '../InteractionHandler'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; import type { InteractionRoute } from './InteractionRoute'; /** - * InteractionHandler that only accepts operations with an expected path. + * InteractionHandler that only accepts input of which the target matches the stored route. * - * Rejects operations that target a different path, + * Rejects operations that target a different route, * otherwise the input parameters are passed to the source handler. */ -export class InteractionRouteHandler extends InteractionHandler { - private readonly route: InteractionRoute; - private readonly source: InteractionHandler; +export class InteractionRouteHandler> extends JsonInteractionHandler { + protected readonly route: T; + protected readonly source: JsonInteractionHandler; - public constructor(route: InteractionRoute, source: InteractionHandler) { + public constructor(route: T, source: JsonInteractionHandler) { super(); this.route = route; this.source = source; } - public async canHandle(input: InteractionHandlerInput): Promise { - const { target } = input.operation; - const path = this.route.getPath(); - if (target.path !== path) { + public async canHandle(input: JsonInteractionHandlerInput): Promise { + const { target } = input; + + if (!this.route.matchPath(target.path)) { throw new NotFoundHttpError(); } await this.source.canHandle(input); } - public async handle(input: InteractionHandlerInput): Promise { + public async handle(input: JsonInteractionHandlerInput): Promise { return this.source.handle(input); } } diff --git a/src/identity/interaction/routing/RelativePathInteractionRoute.ts b/src/identity/interaction/routing/RelativePathInteractionRoute.ts index 70e7d0e93..ed09f19fc 100644 --- a/src/identity/interaction/routing/RelativePathInteractionRoute.ts +++ b/src/identity/interaction/routing/RelativePathInteractionRoute.ts @@ -1,5 +1,5 @@ -import { joinUrl } from '../../../util/PathUtil'; -import { AbsolutePathInteractionRoute } from './AbsolutePathInteractionRoute'; +import { InternalServerError } from '../../../util/errors/InternalServerError'; +import { ensureTrailingSlash, joinUrl, trimLeadingSlashes } from '../../../util/PathUtil'; import type { InteractionRoute } from './InteractionRoute'; /** @@ -7,10 +7,35 @@ import type { InteractionRoute } from './InteractionRoute'; * The relative path will be joined to the input base, * which can either be an absolute URL or an InteractionRoute of which the path will be used. */ -export class RelativePathInteractionRoute extends AbsolutePathInteractionRoute { - public constructor(base: InteractionRoute | string, relativePath: string) { - const url = typeof base === 'string' ? base : base.getPath(); - const path = joinUrl(url, relativePath); - super(path); +export class RelativePathInteractionRoute implements InteractionRoute { + private readonly base: InteractionRoute; + private readonly relativePath: string; + + public constructor(base: InteractionRoute, relativePath: string, ensureSlash = true) { + this.base = base; + this.relativePath = trimLeadingSlashes(relativePath); + if (ensureSlash) { + this.relativePath = ensureTrailingSlash(this.relativePath); + } + } + + public getPath(parameters?: Record): string { + const path = this.base.getPath(parameters); + if (!path.endsWith('/')) { + throw new InternalServerError( + `Expected ${path} to end on a slash so it could be extended. This indicates a configuration error.`, + ); + } + return joinUrl(path, this.relativePath); + } + + public matchPath(path: string): Record | undefined { + if (!path.endsWith(this.relativePath)) { + return; + } + + const head = path.slice(0, -this.relativePath.length); + + return this.base.matchPath(head); } } diff --git a/src/identity/interaction/webid/LinkWebIdHandler.ts b/src/identity/interaction/webid/LinkWebIdHandler.ts new file mode 100644 index 000000000..55d29a20b --- /dev/null +++ b/src/identity/interaction/webid/LinkWebIdHandler.ts @@ -0,0 +1,100 @@ +import { object } from 'yup'; +import { getLoggerFor } from '../../../logging/LogUtil'; +import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; +import type { IdentifierStrategy } from '../../../util/identifiers/IdentifierStrategy'; +import type { OwnershipValidator } from '../../ownership/OwnershipValidator'; +import type { AccountStore } from '../account/util/AccountStore'; +import { getRequiredAccount } from '../account/util/AccountUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import type { JsonView } from '../JsonView'; +import { parseSchema, URL_SCHEMA, validateWithError } from '../YupUtil'; +import type { WebIdStore } from './util/WebIdStore'; +import type { WebIdLinkRoute } from './WebIdLinkRoute'; + +const inSchema = object({ + webId: URL_SCHEMA.required(), +}); + +type OutType = { + resource: string; + webId: string; + oidcIssuer: string; +}; + +export interface LinkWebIdHandlerArgs { + /** + * Base URL of the server. + * Used to indicate in the response what the object of the `solid:oidcIssuer` triple should be. + */ + baseUrl: string; + /** + * Validates whether the user trying to link the WebID is the actual owner of that WebID. + */ + ownershipValidator: OwnershipValidator; + /** + * Account store to store updated data. + */ + accountStore: AccountStore; + /** + * WebID store to store WebID links. + */ + webIdStore: WebIdStore; + /** + * Route used to generate the WebID link resource URL. + */ + webIdRoute: WebIdLinkRoute; + /** + * Before calling the {@link OwnershipValidator}, we first check if the target WebID is in a pod owned by the user. + */ + identifierStrategy: IdentifierStrategy; +} + +/** + * Handles the linking of WebIDs to account, + * thereby registering them to the server IDP. + */ +export class LinkWebIdHandler extends JsonInteractionHandler implements JsonView { + private readonly logger = getLoggerFor(this); + + private readonly baseUrl: string; + private readonly ownershipValidator: OwnershipValidator; + private readonly accountStore: AccountStore; + private readonly webIdStore: WebIdStore; + private readonly identifierStrategy: IdentifierStrategy; + + public constructor(args: LinkWebIdHandlerArgs) { + super(); + this.baseUrl = args.baseUrl; + this.ownershipValidator = args.ownershipValidator; + this.accountStore = args.accountStore; + this.webIdStore = args.webIdStore; + this.identifierStrategy = args.identifierStrategy; + } + + public async getView(): Promise { + return { json: parseSchema(inSchema) }; + } + + public async handle({ accountId, json }: JsonInteractionHandlerInput): Promise> { + const account = await getRequiredAccount(this.accountStore, accountId); + + const { webId } = await validateWithError(inSchema, json); + + if (account.webIds[webId]) { + this.logger.warn(`Trying to link WebID ${webId} to account ${accountId} which already has this link`); + throw new BadRequestHttpError(`${webId} is already registered to this account.`); + } + + // Only need to check ownership if the account is not the owner + const isOwner = Object.keys(account.pods) + .some((pod): boolean => this.identifierStrategy.contains({ path: pod }, { path: webId }, true)); + if (!isOwner) { + await this.ownershipValidator.handleSafe({ webId }); + } + const resource = await this.webIdStore.add(webId, account); + + return { json: { resource, webId, oidcIssuer: this.baseUrl }}; + } +} diff --git a/src/identity/interaction/webid/UnlinkWebIdHandler.ts b/src/identity/interaction/webid/UnlinkWebIdHandler.ts new file mode 100644 index 000000000..be39104c8 --- /dev/null +++ b/src/identity/interaction/webid/UnlinkWebIdHandler.ts @@ -0,0 +1,32 @@ +import type { EmptyObject } from '../../../util/map/MapUtil'; +import type { AccountStore } from '../account/util/AccountStore'; +import { ensureResource, getRequiredAccount } from '../account/util/AccountUtil'; +import type { JsonRepresentation } from '../InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; +import { JsonInteractionHandler } from '../JsonInteractionHandler'; +import type { WebIdStore } from './util/WebIdStore'; + +/** + * Allows users to remove WebIDs linked to their account. + */ +export class UnlinkWebIdHandler extends JsonInteractionHandler { + private readonly accountStore: AccountStore; + private readonly webIdStore: WebIdStore; + + public constructor(accountStore: AccountStore, webIdStore: WebIdStore) { + super(); + this.accountStore = accountStore; + this.webIdStore = webIdStore; + } + + public async handle({ target, accountId }: JsonInteractionHandlerInput): Promise> { + const account = await getRequiredAccount(this.accountStore, accountId); + + const webId = ensureResource(account.webIds, target.path); + + // This also deletes it from the account + await this.webIdStore.delete(webId, account); + + return { json: {}}; + } +} diff --git a/src/identity/interaction/webid/WebIdLinkRoute.ts b/src/identity/interaction/webid/WebIdLinkRoute.ts new file mode 100644 index 000000000..36ff066c9 --- /dev/null +++ b/src/identity/interaction/webid/WebIdLinkRoute.ts @@ -0,0 +1,19 @@ +import type { AccountIdKey, AccountIdRoute } from '../account/AccountIdRoute'; +import { IdInteractionRoute } from '../routing/IdInteractionRoute'; +import type { ExtendedRoute } from '../routing/InteractionRoute'; + +export type WebIdLinkKey = 'webIdLink'; + +/** + * An {@link AccountIdRoute} that also includes a Web ID link identifier. + */ +export type WebIdLinkRoute = ExtendedRoute; + +/** + * Implementation of an {@link WebIdLinkRoute} that adds the identifier relative to a base {@link AccountIdRoute}. + */ +export class BaseWebIdLinkRoute extends IdInteractionRoute { + public constructor(base: AccountIdRoute) { + super(base, 'webIdLink'); + } +} diff --git a/src/identity/interaction/webid/util/BaseWebIdStore.ts b/src/identity/interaction/webid/util/BaseWebIdStore.ts new file mode 100644 index 000000000..c32fb0d79 --- /dev/null +++ b/src/identity/interaction/webid/util/BaseWebIdStore.ts @@ -0,0 +1,74 @@ +import { createHash } from 'crypto'; +import { getLoggerFor } from '../../../../logging/LogUtil'; +import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; +import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; +import type { Account } from '../../account/util/Account'; +import type { AccountStore } from '../../account/util/AccountStore'; +import { safeUpdate } from '../../account/util/AccountUtil'; +import type { WebIdLinkRoute } from '../WebIdLinkRoute'; +import type { WebIdStore } from './WebIdStore'; + +/** + * A {@link WebIdStore} using a {@link KeyValueStorage} to store the links. + * Keys of the storage are WebIDs, values all the account IDs they are linked to. + */ +export class BaseWebIdStore implements WebIdStore { + private readonly logger = getLoggerFor(this); + + private readonly webIdRoute: WebIdLinkRoute; + private readonly accountStore: AccountStore; + private readonly storage: KeyValueStorage; + + public constructor(webIdRoute: WebIdLinkRoute, accountStore: AccountStore, + storage: KeyValueStorage) { + this.webIdRoute = webIdRoute; + this.accountStore = accountStore; + this.storage = storage; + } + + public async get(webId: string): Promise { + return await this.storage.get(webId) ?? []; + } + + public async add(webId: string, account: Account): Promise { + const accounts = await this.storage.get(webId) ?? []; + + if (account.webIds[webId]) { + this.logger.warn(`Trying to link WebID ${webId} which is already linked to this account ${account.id}`); + throw new BadRequestHttpError(`${webId} is already registered to this account.`); + } + + if (!accounts.includes(account.id)) { + accounts.push(account.id); + } + + const webIdLink = createHash('sha256').update(webId).digest('hex'); + const resource = this.webIdRoute.getPath({ accountId: account.id, webIdLink }); + account.webIds[webId] = resource; + + await safeUpdate(account, + this.accountStore, + async(): Promise => this.storage.set(webId, accounts)); + + this.logger.debug(`Linked WebID ${webId} to account ${account.id}`); + + return resource; + } + + public async delete(webId: string, account: Account): Promise { + let accounts = await this.storage.get(webId) ?? []; + + if (accounts.includes(account.id)) { + accounts = accounts.filter((id): boolean => id !== account.id); + delete account.webIds[webId]; + + await safeUpdate(account, + this.accountStore, + async(): Promise => accounts.length === 0 ? + this.storage.delete(webId) : + this.storage.set(webId, accounts)); + + this.logger.debug(`Deleted WebID ${webId} from account ${account.id}`); + } + } +} diff --git a/src/identity/interaction/webid/util/WebIdStore.ts b/src/identity/interaction/webid/util/WebIdStore.ts new file mode 100644 index 000000000..01be9916a --- /dev/null +++ b/src/identity/interaction/webid/util/WebIdStore.ts @@ -0,0 +1,31 @@ +import type { Account } from '../../account/util/Account'; + +/** + * Stores and updates WebID to Account links. + */ +export interface WebIdStore { + /** + * Finds all account IDs that are linked to the given WebID. + * + * @param webId - WebID to find account IDs for. + */ + get: (webId: string) => Promise; + /** + * Adds the given account ID to the WebID. + * Updates the account accordingly. + * + * @param webId - WebID to link to. + * @param account - Account to link to the WebID. Will be updated in place. + * + * @returns The resource corresponding to the created link for this account. + */ + add: (webId: string, account: Account) => Promise; + /** + * Deletes the link between the given WebID and account. + * Updates the account accordingly. + * + * @param webId - WebID to remove the link from. + * @param account - Account to unlink from the WebID. Will be updated in place. + */ + delete: (webId: string, account: Account) => Promise; +} diff --git a/src/index.ts b/src/index.ts index fae12556e..8b4ab83d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,8 +67,10 @@ export * from './http/input/identifier/OriginalUrlExtractor'; export * from './http/input/identifier/TargetExtractor'; // HTTP/Input/Metadata +export * from './http/input/metadata/AuthorizationParser'; export * from './http/input/metadata/ContentLengthParser'; export * from './http/input/metadata/ContentTypeParser'; +export * from './http/input/metadata/CookieParser'; export * from './http/input/metadata/LinkRelParser'; export * from './http/input/metadata/MetadataParser'; export * from './http/input/metadata/PlainJsonLdFilter'; @@ -104,6 +106,7 @@ export * from './http/output/metadata/AllowAcceptHeaderWriter'; export * from './http/output/metadata/AuxiliaryLinkMetadataWriter'; export * from './http/output/metadata/ConstantMetadataWriter'; export * from './http/output/metadata/ContentTypeMetadataWriter'; +export * from './http/output/metadata/CookieMetadataWriter'; export * from './http/output/metadata/LinkRelMetadataWriter'; export * from './http/output/metadata/MappedMetadataWriter'; export * from './http/output/metadata/MetadataWriter'; @@ -137,52 +140,107 @@ export * from './http/Operation'; export * from './http/UnsecureWebSocketsProtocol'; // Identity/Configuration +export * from './identity/configuration/AccountPromptFactory'; export * from './identity/configuration/CachedJwkGenerator'; export * from './identity/configuration/IdentityProviderFactory'; export * from './identity/configuration/JwkGenerator'; +export * from './identity/configuration/PromptFactory'; export * from './identity/configuration/ProviderFactory'; -// Identity/Interaction/Email-Password/Credentials -export * from './identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory'; -export * from './identity/interaction/email-password/credentials/EmailPasswordAuthorizer'; -export * from './identity/interaction/email-password/credentials/CreateCredentialsHandler'; -export * from './identity/interaction/email-password/credentials/CredentialsHandler'; -export * from './identity/interaction/email-password/credentials/DeleteCredentialsHandler'; -export * from './identity/interaction/email-password/credentials/ListCredentialsHandler'; +// Identity/Interaction/Account/Util +export * from './identity/interaction/account/util/Account'; +export * from './identity/interaction/account/util/AccountUtil'; +export * from './identity/interaction/account/util/AccountStore'; +export * from './identity/interaction/account/util/BaseAccountStore'; +export * from './identity/interaction/account/util/BaseCookieStore'; +export * from './identity/interaction/account/util/CookieStore'; -// Identity/Interaction/Email-Password/Handler -export * from './identity/interaction/email-password/handler/ForgotPasswordHandler'; -export * from './identity/interaction/email-password/handler/LoginHandler'; -export * from './identity/interaction/email-password/handler/RegistrationHandler'; -export * from './identity/interaction/email-password/handler/ResetPasswordHandler'; +// Identity/Interaction/Account +export * from './identity/interaction/account/AccountIdRoute'; +export * from './identity/interaction/account/AccountDetailsHandler'; +export * from './identity/interaction/account/CreateAccountHandler'; -// Identity/Interaction/Email-Password/Storage -export * from './identity/interaction/email-password/storage/AccountStore'; -export * from './identity/interaction/email-password/storage/BaseAccountStore'; +// Identity/Interaction/Client-Credentials/Util +export * from './identity/interaction/client-credentials/util/BaseClientCredentialsStore'; +export * from './identity/interaction/client-credentials/util/ClientCredentialsIdRoute'; +export * from './identity/interaction/client-credentials/util/ClientCredentialsStore'; -// Identity/Interaction/Email-Password/Util -export * from './identity/interaction/email-password/util/BaseEmailSender'; -export * from './identity/interaction/email-password/util/EmailSender'; -export * from './identity/interaction/email-password/util/RegistrationManager'; +// Identity/Interaction/Client-Credentials +export * from './identity/interaction/client-credentials/ClientCredentialsAdapterFactory'; +export * from './identity/interaction/client-credentials/ClientCredentialsDetailsHandler'; +export * from './identity/interaction/client-credentials/CreateClientCredentialsHandler'; +export * from './identity/interaction/client-credentials/DeleteClientCredentialsHandler'; -// Identity/Interaction/Email-Password -export * from './identity/interaction/email-password/EmailPasswordUtil'; +// Identity/Interaction/Login +export * from './identity/interaction/login/LogoutHandler'; +export * from './identity/interaction/login/ResolveLoginHandler'; + +// Identity/Interaction/Oidc +export * from './identity/interaction/oidc/CancelOidcHandler'; +export * from './identity/interaction/oidc/ClientInfoHandler'; +export * from './identity/interaction/oidc/ConsentHandler'; +export * from './identity/interaction/oidc/ForgetWebIdHandler'; +export * from './identity/interaction/oidc/PromptHandler'; +export * from './identity/interaction/oidc/PickWebIdHandler'; + +// Identity/Interaction/Password/Util +export * from './identity/interaction/password/util/BaseEmailSender'; +export * from './identity/interaction/password/util/BaseForgotPasswordStore'; +export * from './identity/interaction/password/util/BasePasswordStore'; +export * from './identity/interaction/password/util/EmailSender'; +export * from './identity/interaction/password/util/ForgotPasswordStore'; +export * from './identity/interaction/password/util/PasswordIdRoute'; +export * from './identity/interaction/password/util/PasswordStore'; + +// Identity/Interaction/Password +export * from './identity/interaction/password/CreatePasswordHandler'; +export * from './identity/interaction/password/DeletePasswordHandler'; +export * from './identity/interaction/password/ForgotPasswordHandler'; +export * from './identity/interaction/password/PasswordLoginHandler'; +export * from './identity/interaction/password/ResetPasswordHandler'; +export * from './identity/interaction/password/UpdatePasswordHandler'; + +// Identity/Interaction/Pod/Util +export * from './identity/interaction/pod/util/BasePodStore'; +export * from './identity/interaction/pod/util/PodStore'; + +// Identity/Interaction/Pod +export * from './identity/interaction/pod/CreatePodHandler'; +export * from './identity/interaction/pod/PodIdRoute'; // Identity/Interaction/Routing export * from './identity/interaction/routing/AbsolutePathInteractionRoute'; +export * from './identity/interaction/routing/AuthorizedRouteHandler'; +export * from './identity/interaction/routing/IdInteractionRoute'; export * from './identity/interaction/routing/InteractionRoute'; export * from './identity/interaction/routing/InteractionRouteHandler'; export * from './identity/interaction/routing/RelativePathInteractionRoute'; +// Identity/Interaction/WebID/Util +export * from './identity/interaction/webid/util/BaseWebIdStore'; +export * from './identity/interaction/webid/util/WebIdStore'; + +// Identity/Interaction/WebID +export * from './identity/interaction/webid/LinkWebIdHandler'; +export * from './identity/interaction/webid/UnlinkWebIdHandler'; +export * from './identity/interaction/webid/WebIdLinkRoute'; + // Identity/Interaction -export * from './identity/interaction/BaseInteractionHandler'; -export * from './identity/interaction/ConsentHandler'; export * from './identity/interaction/ControlHandler'; -export * from './identity/interaction/FixedInteractionHandler'; +export * from './identity/interaction/CookieInteractionHandler'; export * from './identity/interaction/HtmlViewHandler'; export * from './identity/interaction/InteractionHandler'; +export * from './identity/interaction/InteractionUtil'; +export * from './identity/interaction/JsonConversionHandler'; +export * from './identity/interaction/JsonInteractionHandler'; +export * from './identity/interaction/JsonView'; export * from './identity/interaction/LocationInteractionHandler'; -export * from './identity/interaction/PromptHandler'; +export * from './identity/interaction/LockingInteractionHandler'; +export * from './identity/interaction/OidcControlHandler'; +export * from './identity/interaction/StaticInteractionHandler'; +export * from './identity/interaction/VersionHandler'; +export * from './identity/interaction/ViewInteractionHandler'; +export * from './identity/interaction/YupUtil'; // Identity/Ownership export * from './identity/ownership/NoCheckOwnershipValidator'; @@ -235,7 +293,7 @@ export * from './init/InitializableHandler'; export * from './init/Initializer'; export * from './init/LoggerInitializer'; export * from './init/ModuleVersionVerifier'; -export * from './init/SeededPodInitializer'; +export * from './init/SeededAccountInitializer'; export * from './init/ServerInitializer'; // Logging diff --git a/src/init/SeededAccountInitializer.ts b/src/init/SeededAccountInitializer.ts new file mode 100644 index 000000000..a80664bfe --- /dev/null +++ b/src/init/SeededAccountInitializer.ts @@ -0,0 +1,105 @@ +import { readJson } from 'fs-extra'; +import { array, object, string } from 'yup'; +import { RepresentationMetadata } from '../http/representation/RepresentationMetadata'; +import type { JsonInteractionHandler } from '../identity/interaction/JsonInteractionHandler'; +import type { ResolveLoginHandler } from '../identity/interaction/login/ResolveLoginHandler'; +import { URL_SCHEMA } from '../identity/interaction/YupUtil'; +import { getLoggerFor } from '../logging/LogUtil'; +import { createErrorMessage } from '../util/errors/ErrorUtil'; +import { Initializer } from './Initializer'; + +const inSchema = array().of(object({ + email: string().trim().email().lowercase() + .required(), + password: string().trim().min(1).required(), + pods: array().of(object({ + name: string().trim().min(1).required(), + settings: object({ + webId: URL_SCHEMA, + }).optional(), + })).optional(), +})).required(); + +export interface SeededAccountInitializerArgs { + /** + * Creates the accounts. + */ + accountHandler: ResolveLoginHandler; + /** + * Adds the login methods. + */ + passwordHandler: JsonInteractionHandler; + /** + * Creates the pods. + */ + podHandler: JsonInteractionHandler; + /** + * File path of the JSON describing the accounts to seed. + */ + configFilePath?: string; +} + +/** + * Initializes a set of accounts based on the input data. + * These accounts have exactly 1 email/password login method, and 0 or more pods. + * The pod settings that can be defined are identical to those of the {@link CreatePodHandler}. + */ +export class SeededAccountInitializer extends Initializer { + protected readonly logger = getLoggerFor(this); + + private readonly accountHandler: ResolveLoginHandler; + private readonly passwordHandler: JsonInteractionHandler; + private readonly podHandler: JsonInteractionHandler; + private readonly configFilePath?: string; + + public constructor(args: SeededAccountInitializerArgs) { + super(); + this.accountHandler = args.accountHandler; + this.passwordHandler = args.passwordHandler; + this.podHandler = args.podHandler; + this.configFilePath = args.configFilePath; + } + + public async handle(): Promise { + // This value being undefined means that the variable linking to the seed config is not defined + // and this class should just do nothing. + if (!this.configFilePath) { + return; + } + + let configuration: typeof inSchema.__outputType; + try { + configuration = await inSchema.validate(await readJson(this.configFilePath, 'utf8')); + } catch (error: unknown) { + const msg = `Invalid account seed file: ${createErrorMessage(error)}`; + this.logger.error(msg); + throw new Error(msg); + } + + // Dummy data for requests to all the handlers + const method = 'POST'; + const target = { path: '' }; + const metadata = new RepresentationMetadata(); + + let accounts = 0; + let pods = 0; + for await (const input of configuration) { + try { + this.logger.info(`Creating account for ${input.email}`); + const accountResult = await this.accountHandler.login({ method, target, metadata, json: {}}); + const { accountId } = accountResult.json; + await this.passwordHandler.handleSafe({ method, target, metadata, accountId, json: input }); + accounts += 1; + + for (const pod of input.pods ?? []) { + this.logger.info(`Creating pod with name ${pod.name}`); + await this.podHandler.handleSafe({ method, target, metadata, accountId, json: pod }); + pods += 1; + } + } catch (error: unknown) { + this.logger.warn(`Error while initializing seeded account: ${createErrorMessage(error)}`); + } + } + this.logger.info(`Initialized ${accounts} accounts and ${pods} pods.`); + } +} diff --git a/src/init/SeededPodInitializer.ts b/src/init/SeededPodInitializer.ts deleted file mode 100644 index 1e76fb292..000000000 --- a/src/init/SeededPodInitializer.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { readJson } from 'fs-extra'; -import type { RegistrationManager } from '../identity/interaction/email-password/util/RegistrationManager'; -import { getLoggerFor } from '../logging/LogUtil'; -import { createErrorMessage } from '../util/errors/ErrorUtil'; -import { Initializer } from './Initializer'; - -/** - * Uses a {@link RegistrationManager} to initialize accounts and pods - * for all seeded pods. Reads the pod settings from seededPodConfigJson. - */ -export class SeededPodInitializer extends Initializer { - protected readonly logger = getLoggerFor(this); - - private readonly registrationManager: RegistrationManager; - private readonly configFilePath: string | null; - - public constructor(registrationManager: RegistrationManager, configFilePath: string | null) { - super(); - this.registrationManager = registrationManager; - this.configFilePath = configFilePath; - } - - public async handle(): Promise { - if (!this.configFilePath) { - return; - } - const configuration = await readJson(this.configFilePath, 'utf8'); - - let count = 0; - for await (const input of configuration) { - const config = { - confirmPassword: input.password, - createPod: true, - createWebId: true, - register: true, - ...input, - }; - - this.logger.info(`Initializing pod ${input.podName}`); - - // Validate the input JSON - const validated = this.registrationManager.validateInput(config, true); - this.logger.debug(`Validated input: ${JSON.stringify(validated)}`); - - // Register and/or create a pod as requested. Potentially does nothing if all booleans are false. - try { - await this.registrationManager.register(validated, true); - this.logger.info(`Initialized seeded pod and account for "${input.podName}".`); - count += 1; - } catch (error: unknown) { - this.logger.warn(`Error while initializing seeded pod: ${createErrorMessage(error)})}`); - } - } - - this.logger.info(`Initialized ${count} seeded pods.`); - } -} diff --git a/src/pods/ConfigPodManager.ts b/src/pods/ConfigPodManager.ts index f7526a876..b61bed123 100644 --- a/src/pods/ConfigPodManager.ts +++ b/src/pods/ConfigPodManager.ts @@ -1,4 +1,3 @@ -import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import { getLoggerFor } from '../logging/LogUtil'; import type { KeyValueStorage } from '../storage/keyvalue/KeyValueStorage'; import type { ResourceStore } from '../storage/ResourceStore'; @@ -40,15 +39,15 @@ export class ConfigPodManager implements PodManager { this.store = store; } - public async createPod(identifier: ResourceIdentifier, settings: PodSettings): Promise { - this.logger.info(`Creating pod ${identifier.path}`); + public async createPod(settings: PodSettings): Promise { + this.logger.info(`Creating pod ${settings.base.path}`); // Will error in case there already is a store for the given identifier - const store = await this.podGenerator.generate(identifier, settings); + const store = await this.podGenerator.generate(settings); - await this.routingStorage.set(identifier.path, store); - const count = await addGeneratedResources(identifier, settings, this.resourcesGenerator, this.store); + await this.routingStorage.set(settings.base.path, store); + const count = await addGeneratedResources(settings, this.resourcesGenerator, this.store); - this.logger.info(`Added ${count} resources to ${identifier.path}`); + this.logger.info(`Added ${count} resources to ${settings.base.path}`); } } diff --git a/src/pods/GeneratedPodManager.ts b/src/pods/GeneratedPodManager.ts index 2b3971de6..142675f44 100644 --- a/src/pods/GeneratedPodManager.ts +++ b/src/pods/GeneratedPodManager.ts @@ -1,4 +1,3 @@ -import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import { getLoggerFor } from '../logging/LogUtil'; import type { ResourceStore } from '../storage/ResourceStore'; import { ConflictHttpError } from '../util/errors/ConflictHttpError'; @@ -26,13 +25,13 @@ export class GeneratedPodManager implements PodManager { * Creates a new pod, pre-populating it with the resources created by the data generator. * Will throw an error if the given identifier already has a resource. */ - public async createPod(identifier: ResourceIdentifier, settings: PodSettings, overwrite: boolean): Promise { - this.logger.info(`Creating pod ${identifier.path}`); - if (!overwrite && await this.store.hasResource(identifier)) { - throw new ConflictHttpError(`There already is a resource at ${identifier.path}`); + public async createPod(settings: PodSettings, overwrite: boolean): Promise { + this.logger.info(`Creating pod ${settings.base.path}`); + if (!overwrite && await this.store.hasResource(settings.base)) { + throw new ConflictHttpError(`There already is a resource at ${settings.base.path}`); } - const count = await addGeneratedResources(identifier, settings, this.resourcesGenerator, this.store); - this.logger.info(`Added ${count} resources to ${identifier.path}`); + const count = await addGeneratedResources(settings, this.resourcesGenerator, this.store); + this.logger.info(`Added ${count} resources to ${settings.base.path}`); } } diff --git a/src/pods/PodManager.ts b/src/pods/PodManager.ts index c61544225..b696faaeb 100644 --- a/src/pods/PodManager.ts +++ b/src/pods/PodManager.ts @@ -1,4 +1,3 @@ -import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import type { PodSettings } from './settings/PodSettings'; /** @@ -8,9 +7,8 @@ import type { PodSettings } from './settings/PodSettings'; export interface PodManager { /** * Creates a pod for the given settings. - * @param identifier - Root identifier indicating where the pod should be created. * @param settings - Settings describing the pod. * @param overwrite - If the creation should proceed if there already is a resource there. */ - createPod: (identifier: ResourceIdentifier, settings: PodSettings, overwrite: boolean) => Promise; + createPod: (settings: PodSettings, overwrite: boolean) => Promise; } diff --git a/src/pods/generate/BaseResourcesGenerator.ts b/src/pods/generate/BaseResourcesGenerator.ts index 57b7b02f6..72d1fb40c 100644 --- a/src/pods/generate/BaseResourcesGenerator.ts +++ b/src/pods/generate/BaseResourcesGenerator.ts @@ -93,7 +93,7 @@ export class BaseResourcesGenerator implements TemplatedResourcesGenerator { this.store = args.store; } - public async* generate(templateFolder: string, location: ResourceIdentifier, options: Dict): + public async* generate(templateFolder: string, location: ResourceIdentifier, options: Dict): AsyncIterable { templateFolder = resolveAssetPath(templateFolder); @@ -111,7 +111,7 @@ export class BaseResourcesGenerator implements TemplatedResourcesGenerator { /** * Generates results for all entries in the given folder, including the folder itself. */ - private async* processFolder(folderLink: TemplateResourceLink, mapper: FileIdentifierMapper, options: Dict): + private async* processFolder(folderLink: TemplateResourceLink, mapper: FileIdentifierMapper, options: Dict): AsyncIterable { // Group resource links with their corresponding metadata links const links = await this.groupLinks(folderLink.filePath, mapper); @@ -175,7 +175,7 @@ export class BaseResourcesGenerator implements TemplatedResourcesGenerator { * If a ResourceLink of metadata is provided the corresponding metadata resource * will be yielded as a separate resource. */ - private async* generateResource(link: TemplateResourceLink, options: Dict, metaLink?: TemplateResourceLink): + private async* generateResource(link: TemplateResourceLink, options: Dict, metaLink?: TemplateResourceLink): AsyncIterable { let data: Guarded | undefined; const metadata = new RepresentationMetadata(link.identifier); @@ -211,7 +211,7 @@ export class BaseResourcesGenerator implements TemplatedResourcesGenerator { /** * Generates a RepresentationMetadata using the given template. */ - private async generateMetadata(metaLink: TemplateResourceLink, options: Dict): + private async generateMetadata(metaLink: TemplateResourceLink, options: Dict): Promise { const metadata = new RepresentationMetadata(metaLink.identifier); @@ -226,7 +226,7 @@ export class BaseResourcesGenerator implements TemplatedResourcesGenerator { /** * Creates a read stream from the file and applies the template if necessary. */ - private async processFile(link: TemplateResourceLink, contents: Dict): Promise> { + private async processFile(link: TemplateResourceLink, contents: Dict): Promise> { if (link.isTemplate) { const rendered = await this.templateEngine.handleSafe({ contents, template: { templateFile: link.filePath }}); return guardedStreamFrom(rendered); diff --git a/src/pods/generate/GenerateUtil.ts b/src/pods/generate/GenerateUtil.ts index 14e477c61..44fbde7b4 100644 --- a/src/pods/generate/GenerateUtil.ts +++ b/src/pods/generate/GenerateUtil.ts @@ -1,19 +1,18 @@ -import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import type { ResourceStore } from '../../storage/ResourceStore'; +import type { PodSettings } from '../settings/PodSettings'; import type { ResourcesGenerator } from './ResourcesGenerator'; /** * Generates resources with the given generator and adds them to the given store. - * @param identifier - Identifier of the pod. * @param settings - Settings from which the pod is being created. * @param generator - Generator to be used. * @param store - Store to be updated. * * @returns The amount of resources that were added. */ -export async function addGeneratedResources(identifier: ResourceIdentifier, settings: NodeJS.Dict, - generator: ResourcesGenerator, store: ResourceStore): Promise { - const resources = generator.generate(identifier, settings); +export async function addGeneratedResources(settings: PodSettings, generator: ResourcesGenerator, + store: ResourceStore): Promise { + const resources = generator.generate(settings.base, settings); let count = 0; for await (const { identifier: resourceId, representation } of resources) { await store.setRepresentation(resourceId, representation); diff --git a/src/pods/generate/PodGenerator.ts b/src/pods/generate/PodGenerator.ts index 68757dd5a..dfa6f66a5 100644 --- a/src/pods/generate/PodGenerator.ts +++ b/src/pods/generate/PodGenerator.ts @@ -1,4 +1,3 @@ -import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import type { ResourceStore } from '../../storage/ResourceStore'; import type { PodSettings } from '../settings/PodSettings'; @@ -12,10 +11,9 @@ export interface PodGenerator { * Creates a ResourceStore based on the given input. * Should error if there already is a store for the given identifier. * - * @param identifier - Identifier of the new pod. * @param settings - Parameters to be used for the new pod. * * @returns A new ResourceStore to be used for the new pod. */ - generate: (identifier: ResourceIdentifier, settings: PodSettings) => Promise; + generate: (settings: PodSettings) => Promise; } diff --git a/src/pods/generate/ResourcesGenerator.ts b/src/pods/generate/ResourcesGenerator.ts index a6184cab0..b8089c16a 100644 --- a/src/pods/generate/ResourcesGenerator.ts +++ b/src/pods/generate/ResourcesGenerator.ts @@ -20,5 +20,5 @@ export interface ResourcesGenerator { * * @returns A map where the keys are the identifiers and the values the corresponding representations to store. */ - generate: (location: ResourceIdentifier, options: Dict) => AsyncIterable; + generate: (location: ResourceIdentifier, options: Dict) => AsyncIterable; } diff --git a/src/pods/generate/StaticFolderGenerator.ts b/src/pods/generate/StaticFolderGenerator.ts index 23c4ce112..1d43c83c9 100644 --- a/src/pods/generate/StaticFolderGenerator.ts +++ b/src/pods/generate/StaticFolderGenerator.ts @@ -15,7 +15,7 @@ export class StaticFolderGenerator implements ResourcesGenerator { this.templateFolder = templateFolder; } - public generate(location: ResourceIdentifier, options: Dict): AsyncIterable { + public generate(location: ResourceIdentifier, options: Dict): AsyncIterable { return this.resourcesGenerator.generate(this.templateFolder, location, options); } } diff --git a/src/pods/generate/SubfolderResourcesGenerator.ts b/src/pods/generate/SubfolderResourcesGenerator.ts index f1c26f542..f0612071d 100644 --- a/src/pods/generate/SubfolderResourcesGenerator.ts +++ b/src/pods/generate/SubfolderResourcesGenerator.ts @@ -30,7 +30,7 @@ export class SubfolderResourcesGenerator implements TemplatedResourcesGenerator this.subfolders = subfolders; } - public async* generate(templateFolder: string, location: ResourceIdentifier, options: Dict): + public async* generate(templateFolder: string, location: ResourceIdentifier, options: Dict): AsyncIterable { const root = resolveAssetPath(templateFolder); const templateSubfolders = this.subfolders.map((subfolder): string => joinFilePath(root, subfolder)); diff --git a/src/pods/generate/TemplatedPodGenerator.ts b/src/pods/generate/TemplatedPodGenerator.ts index 84883eb7b..00f21a0ff 100644 --- a/src/pods/generate/TemplatedPodGenerator.ts +++ b/src/pods/generate/TemplatedPodGenerator.ts @@ -1,4 +1,3 @@ -import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import { getLoggerFor } from '../../logging/LogUtil'; import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; import type { ResourceStore } from '../../storage/ResourceStore'; @@ -48,7 +47,8 @@ export class TemplatedPodGenerator implements PodGenerator { this.configTemplatePath = configTemplatePath ?? DEFAULT_CONFIG_PATH; } - public async generate(identifier: ResourceIdentifier, settings: PodSettings): Promise { + public async generate(settings: PodSettings): Promise { + const identifier = settings.base; if (!settings.template) { throw new BadRequestHttpError('Settings require template field.'); } @@ -61,7 +61,7 @@ export class TemplatedPodGenerator implements PodGenerator { await this.variableHandler.handleSafe({ identifier, settings }); // Filter out irrelevant data in the agent - const variables: NodeJS.Dict = {}; + const variables: NodeJS.Dict = {}; for (const key of Object.keys(settings)) { if (isValidVariable(key)) { variables[key] = settings[key]; @@ -78,7 +78,7 @@ export class TemplatedPodGenerator implements PodGenerator { const store: ResourceStore = await this.storeFactory.generate( - variables[TEMPLATE_VARIABLE.templateConfig]!, + variables[TEMPLATE_VARIABLE.templateConfig] as string, TEMPLATE.ResourceStore, { ...variables, 'urn:solid-server:default:variable:baseUrl': this.baseUrl }, ); diff --git a/src/pods/generate/TemplatedResourcesGenerator.ts b/src/pods/generate/TemplatedResourcesGenerator.ts index 1d1b8fda1..f097ba952 100644 --- a/src/pods/generate/TemplatedResourcesGenerator.ts +++ b/src/pods/generate/TemplatedResourcesGenerator.ts @@ -17,5 +17,5 @@ export interface TemplatedResourcesGenerator { * * @returns A map where the keys are the identifiers and the values the corresponding representations to store. */ - generate: (templateFolder: string, location: ResourceIdentifier, options: Dict) => AsyncIterable; + generate: (templateFolder: string, location: ResourceIdentifier, options: Dict) => AsyncIterable; } diff --git a/src/pods/settings/PodSettings.ts b/src/pods/settings/PodSettings.ts index db92fb5bb..b922db004 100644 --- a/src/pods/settings/PodSettings.ts +++ b/src/pods/settings/PodSettings.ts @@ -1,9 +1,13 @@ +import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; + /** * Metadata related to pod generation. - * Although the optional fields are not that relevant since this extends Dict, - * they give an indication of what is sometimes expected. */ -export interface PodSettings extends NodeJS.Dict { +export interface PodSettings extends NodeJS.Dict { + /** + * The root of the pod. Determines where the pod will be created. + */ + base: ResourceIdentifier; /** * The WebId of the owner of this pod. */ @@ -22,7 +26,7 @@ export interface PodSettings extends NodeJS.Dict { */ email?: string; /** - * The OIDC issuer of the owner's WebId. + * The OIDC issuer of the owner's WebId. Necessary if the WebID in the pod is registered with the IDP. */ oidcIssuer?: string; } diff --git a/src/util/StringUtil.ts b/src/util/StringUtil.ts index a51722750..b8bedd3e9 100644 --- a/src/util/StringUtil.ts +++ b/src/util/StringUtil.ts @@ -29,6 +29,22 @@ export function isValidFileName(name: string): boolean { return /^[\w.-]+$/u.test(name); } +/** + * Checks if the given string is a valid URL. + * + * @param url - String to check. + * @returns True if the string is a valid URL. + */ +export function isUrl(url: string): boolean { + try { + // eslint-disable-next-line no-new + new URL(url); + return true; + } catch { + return false; + } +} + /** * Converts milliseconds to an ISO 8601 duration string. * The only categories used are days, hours, minutes, and seconds, diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 2ca571970..1afdfa665 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -266,6 +266,9 @@ export const SOLID_ERROR_TERM = createVocabulary('urn:npm:solid:community-server ); export const SOLID_HTTP = createVocabulary('urn:npm:solid:community-server:http:', + 'accountCookie', + // When the above cookie expires, expects an ISO date string + 'accountCookieExpiration', // Unit, start, and end are used for range headers 'end', 'location', diff --git a/src/util/errors/MethodNotAllowedHttpError.ts b/src/util/errors/MethodNotAllowedHttpError.ts index e5f6fc07c..ef8850fd5 100644 --- a/src/util/errors/MethodNotAllowedHttpError.ts +++ b/src/util/errors/MethodNotAllowedHttpError.ts @@ -13,7 +13,7 @@ export class MethodNotAllowedHttpError extends BaseHttpError { public readonly methods: Readonly; public constructor(methods: string[] = [], message?: string, options?: HttpErrorOptions) { - super(message ?? `${methods} are not allowed.`, options); + super(message ?? `${methods.join(', ')} ${methods.length === 1 ? 'is' : 'are'} not allowed.`, options); // Can not override `generateMetadata` as `this.methods` is not defined yet for (const method of methods) { this.metadata.add(SOLID_ERROR.terms.disallowedMethod, method); diff --git a/src/util/map/MapUtil.ts b/src/util/map/MapUtil.ts index b26ee8939..c24726ee0 100644 --- a/src/util/map/MapUtil.ts +++ b/src/util/map/MapUtil.ts @@ -2,6 +2,9 @@ import { resolvePromiseOrValue } from '../PromiseUtil'; import type { PromiseOrValue } from '../PromiseUtil'; import type { SetMultiMap } from './SetMultiMap'; +export type ArrayElement = TArray[number]; +export type EmptyObject = Record; + export type MapKey = T extends Map ? TKey : never; export type MapValue = T extends Map ? TValue : never; export type MapEntry = T extends Map ? [MapKey, MapValue] : never; diff --git a/templates/identity/account/account.html.ejs b/templates/identity/account/account.html.ejs new file mode 100644 index 000000000..26cb70031 --- /dev/null +++ b/templates/identity/account/account.html.ejs @@ -0,0 +1,9 @@ + diff --git a/templates/identity/account/create-client-credentials.html.ejs b/templates/identity/account/create-client-credentials.html.ejs new file mode 100644 index 000000000..65768172e --- /dev/null +++ b/templates/identity/account/create-client-credentials.html.ejs @@ -0,0 +1,76 @@ +

Create client credentials token

+
+

+ +
+

Choose a name and which WebID to associate with the token

+
    +
  1. + + +
  2. +
+
    +
+
+ +
    +
  • +
  • +
+
+ + + + + diff --git a/templates/identity/account/create-pod.html.ejs b/templates/identity/account/create-pod.html.ejs new file mode 100644 index 000000000..93f3463dc --- /dev/null +++ b/templates/identity/account/create-pod.html.ejs @@ -0,0 +1,99 @@ +

Create account

+
+

+ +
+

Choose a name for your pod

+
    +
  1. + + +
  2. +
+

Choose which WebID will have initial write permissions on the pod

+
    +
  1. + +
  2. +
  3. + +
      +
    1. + + +
    2. +
    +
  4. +
+
+ +
    +
  • +
  • +
+
+ + + + diff --git a/templates/identity/account/link-webid.html.ejs b/templates/identity/account/link-webid.html.ejs new file mode 100644 index 000000000..e34c29cec --- /dev/null +++ b/templates/identity/account/link-webid.html.ejs @@ -0,0 +1,47 @@ +

Create account

+
+

+ +
+

Choose the WebID to link to this account

+
    +
  1. + + +
  2. +
+
+ +
    +
  • +
  • +
+
+ + + + diff --git a/templates/identity/account/resource.html.ejs b/templates/identity/account/resource.html.ejs new file mode 100644 index 000000000..3e774d040 --- /dev/null +++ b/templates/identity/account/resource.html.ejs @@ -0,0 +1,141 @@ +

Your account

+

+
+

Logins

+

The login methods that can be used to identify as this account.

+
+
+
+

Pods

+

The pods created by this account.

+ Create pod +
    +
    +
    +

    Registered Web IDs

    +

    + These are the WebIDs you can authenticate as using this account. + These WebIDs also have full control access to the pods registered for this account. +

    + Link WebID +
      +
      +
      +

      Credential tokens

      +

      The tokens created by this account.

      + Create token +
        +
        +

        + + +

        + + diff --git a/templates/identity/email-password/consent.html.ejs b/templates/identity/email-password/consent.html.ejs deleted file mode 100644 index 543203e8b..000000000 --- a/templates/identity/email-password/consent.html.ejs +++ /dev/null @@ -1,72 +0,0 @@ -

        An application is requesting access

        -

        - Your WebID is -

        -

        - Do you trust this application - to read and write data on your behalf? -

        -
        -
        -

        - -
        -
          -
        1. - -
        2. -
        -
        - -

        - - - -

        -
        - - diff --git a/templates/identity/email-password/forgot-password.html.ejs b/templates/identity/email-password/forgot-password.html.ejs deleted file mode 100644 index 84789e516..000000000 --- a/templates/identity/email-password/forgot-password.html.ejs +++ /dev/null @@ -1,45 +0,0 @@ -
        -

        Forgot password

        -
        -

        - -
        -
          -
        1. - - -
        2. -
        -
        - -

        - -

        Log in

        -
        -
        -
        -

        Email sent

        -

        If your account exists, an email has been sent with a link to reset your password.

        -

        If you do not receive your email in a couple of minutes, check your spam folder or try sending another email.

        - - -
        - - diff --git a/templates/identity/email-password/login.html.ejs b/templates/identity/email-password/login.html.ejs deleted file mode 100644 index e527daa42..000000000 --- a/templates/identity/email-password/login.html.ejs +++ /dev/null @@ -1,56 +0,0 @@ -
        -

        Log in

        -
        -

        - -
        - Your account -
          -
        1. - - -
        2. -
        3. - - -
        4. -
        5. - -
        6. -
        -
        - -

        - - -
        -
        -
        -

        Please log in through an app

        -

        To log in and access documents, you need to use a Solid app.

        -

        This server provides secure storage, but it is not a client app.

        -

        - Choose one of the - Solid apps - to log in and browse Pods. -

        -

        - If you're developing an app yourself, - use a library such as - solid-client-authn-js - to initiate an OIDC authentication flow. -

        -
        - - - diff --git a/templates/identity/email-password/register-partial.html.ejs b/templates/identity/email-password/register-partial.html.ejs deleted file mode 100644 index 4fe214c01..000000000 --- a/templates/identity/email-password/register-partial.html.ejs +++ /dev/null @@ -1,168 +0,0 @@ -<% - const isBlankForm = !('prefilled' in locals); - prefilled = locals.prefilled || {}; -%> - -
        - Your WebID -

        - A WebID is a unique identifier for you - in the form of a URL. -
        - You WebID lets you log in to Solid apps - and access non-public data in Pods. -

        -
          -
        1. - -

          - Please also create a Pod below, since your WebID will be stored there. -

          -
        2. -
        3. - -
            -
          1. - - -
          2. -
          3. - -
          4. -
          -
        4. -
        -
        - -
        - Your Pod -

        - A Pod is a storage place for your data. -

        -
          -
        1. - -
            - <% if (locals.allowRootPod) { %> - - <% } else { %> -
          1. - - -
          2. - <% } %> -
          -
        2. -
        -
        - -
        - Your account -
        -

        - Choose the credentials you want to use to log in to this server in the future. -

        -
          -
        1. - - -
        2. -
        -
          -
        1. - - -
        2. -
        3. - - -
        4. -
        -
        -
        - - - diff --git a/templates/identity/email-password/register-response-partial.html.ejs b/templates/identity/email-password/register-response-partial.html.ejs deleted file mode 100644 index 4a94e76bc..000000000 --- a/templates/identity/email-password/register-response-partial.html.ejs +++ /dev/null @@ -1,54 +0,0 @@ -
        -

        Your new Pod

        -

        - Your new Pod is located at . -
        - You can store your documents and data there. -

        -
        - -
        -

        Your new WebID

        -

        - Your new WebID is . -
        - You can use this identifier to interact with Solid pods and apps. -

        -
        - -
        -

        Your new account

        -

        - Via your email address , - this server lets you log in to Solid apps - with your WebID -

        -
        -

        - You will need to add the triple - - to your existing WebID document - to indicate that you trust this server as a login provider. -

        -
        -

        - You can now log in. -

        -
        - - diff --git a/templates/identity/email-password/register.html.ejs b/templates/identity/email-password/register.html.ejs deleted file mode 100644 index b88e6b5ec..000000000 --- a/templates/identity/email-password/register.html.ejs +++ /dev/null @@ -1,31 +0,0 @@ -
        -

        Sign up

        -
        -

        - - <%- include('./register-partial.html.ejs', { allowRootPod: false }) %> - -

        -
        -
        -
        -

        You've been signed up

        -

        - Welcome to Solid. - We wish you an exciting experience! -

        - - <%- include('./register-response-partial.html.ejs') %> -
        - - diff --git a/templates/identity/index.html.ejs b/templates/identity/index.html.ejs new file mode 100644 index 000000000..1a5c76c53 --- /dev/null +++ b/templates/identity/index.html.ejs @@ -0,0 +1,12 @@ + diff --git a/templates/identity/login.html.ejs b/templates/identity/login.html.ejs new file mode 100644 index 000000000..dd0ea7508 --- /dev/null +++ b/templates/identity/login.html.ejs @@ -0,0 +1,49 @@ +

        Choose a login method

        +
        +

        + +
        + Login methods +
          +
        +
        + +

        +
        + + diff --git a/templates/identity/oidc/consent.html.ejs b/templates/identity/oidc/consent.html.ejs new file mode 100644 index 000000000..c29fbe9f1 --- /dev/null +++ b/templates/identity/oidc/consent.html.ejs @@ -0,0 +1,111 @@ +

        An application is requesting access

        +

        +

        + Do you trust this application + to read and write data on your behalf? +

        +
        +
        +
        + Choose your WebID to authorize +
          +
        +
        + +
        +
          +
        1. + +
        2. +
        +
        + +

        + + + + +

        +
        + + diff --git a/templates/identity/password/create.html.ejs b/templates/identity/password/create.html.ejs new file mode 100644 index 000000000..50e84407e --- /dev/null +++ b/templates/identity/password/create.html.ejs @@ -0,0 +1,44 @@ +

        Create account

        +
        +

        + +
        +

        Choose the credentials you want to use to log in to this server in the future

        +
          +
        1. + + +
        2. +
        3. + + +
        4. +
        5. + + +
        6. +
        +
        + +
          +
        • +
        • +
        +
        + + + diff --git a/templates/identity/password/forgot.html.ejs b/templates/identity/password/forgot.html.ejs new file mode 100644 index 000000000..b53d4d9b1 --- /dev/null +++ b/templates/identity/password/forgot.html.ejs @@ -0,0 +1,49 @@ +
        +

        Forgot password

        +
        +

        + +
        +
          +
        1. + + +
        2. +
        +
        + +
          +
        • +
        • +
        +
        +
        +
        +

        Email sent

        +

        If your account exists, an email has been sent with a link to reset your password.

        +

        If you do not receive your email in a couple of minutes, check your spam folder or try sending another email.

        + +
          +
        • +
        • +
        +
        + + diff --git a/templates/identity/password/login.html.ejs b/templates/identity/password/login.html.ejs new file mode 100644 index 000000000..6f8d5d0ae --- /dev/null +++ b/templates/identity/password/login.html.ejs @@ -0,0 +1,57 @@ +

        Log in

        +
        +

        + +
        + Your account +
          +
        1. + + +
        2. +
        3. + + +
        4. +
        5. + +
        6. +
        +
        + +

        + + +

        + + +
        + + + diff --git a/templates/identity/password/register.html.ejs b/templates/identity/password/register.html.ejs new file mode 100644 index 000000000..78d1414a2 --- /dev/null +++ b/templates/identity/password/register.html.ejs @@ -0,0 +1,67 @@ +

        Create account

        +
        +

        + +
        +

        Choose the credentials you want to use to log in to this server in the future

        +
          +
        1. + + +
        2. +
        3. + + +
        4. +
        5. + + +
        6. +
        +
        + +

        + + +

        +
        + + + diff --git a/templates/identity/email-password/reset-password-email.html.ejs b/templates/identity/password/reset-email.html.ejs similarity index 100% rename from templates/identity/email-password/reset-password-email.html.ejs rename to templates/identity/password/reset-email.html.ejs diff --git a/templates/identity/email-password/reset-password.html.ejs b/templates/identity/password/reset.html.ejs similarity index 57% rename from templates/identity/email-password/reset-password.html.ejs rename to templates/identity/password/reset.html.ejs index 9f8837250..eb1f504de 100644 --- a/templates/identity/email-password/reset-password.html.ejs +++ b/templates/identity/password/reset.html.ejs @@ -14,7 +14,6 @@ -

        @@ -23,18 +22,24 @@

        Password reset

        Your password was successfully reset.

        +

        diff --git a/templates/identity/password/update.html.ejs b/templates/identity/password/update.html.ejs new file mode 100644 index 000000000..f80ea6ad2 --- /dev/null +++ b/templates/identity/password/update.html.ejs @@ -0,0 +1,42 @@ +

        Change password

        +
        +

        + +
        +
          +
        1. + + +
        2. +
        3. + + +
        4. +
        5. + + +
        6. +
        +
        + +
          +
        • +
        • +
        +
        + + + diff --git a/templates/root/intro/acp/.acr b/templates/root/intro/acp/.acr new file mode 100644 index 000000000..588cbbce0 --- /dev/null +++ b/templates/root/intro/acp/.acr @@ -0,0 +1,32 @@ +# WARNING: DO NOT USE UNMODIFIED UNLESS FOR TESTING PURPOSES. +# WHEN IN DOUBT, DELETE THIS DOCUMENT. +# +# This root ACR allows unrestricted public access to all documents and subcontainers. +# +# This document was automatically generated by the Community Solid Server +# because the "Expose a public root Pod" option was selected during setup, +# or because setup has been bypassed. +# +# We strongly suggest to edit this document such that it restricts permissions. + +@prefix acl: . +@prefix acp: . + +# Give all agents Read, Write, and Control permissions on everything +<#card> + a acp:AccessControlResource; + acp:resource <./>; + acp:accessControl <#publicReadAccess>; + acp:memberAccessControl <#publicReadAccess> . + +<#publicReadAccess> + a acp:AccessControl; + acp:apply [ + a acp:Policy; + acp:allow acl:Read, acl:Write, acl:Control; + acp:anyOf [ + a acp:Matcher; + acp:agent acp:PublicAgent + ] + ]. + diff --git a/templates/root/intro/base/.meta b/templates/root/intro/base/.meta new file mode 100644 index 000000000..8d5a9bc50 --- /dev/null +++ b/templates/root/intro/base/.meta @@ -0,0 +1,7 @@ +@prefix pim: . + +# It is imperative the root container is marked as a pim:Storage : +# Solid, §4.1: "Servers exposing the storage resource MUST advertise by including the HTTP Link header +# with rel="type" targeting http://www.w3.org/ns/pim/space#Storage when responding to storage’s request URI." +# https://solid.github.io/specification/protocol#storage +<> a pim:Storage. diff --git a/templates/root/intro/base/index.html b/templates/root/intro/base/index.html new file mode 100644 index 000000000..5f5528c6f --- /dev/null +++ b/templates/root/intro/base/index.html @@ -0,0 +1,97 @@ + + + + + + Community Solid Server + + + +
        + [Solid logo] +

        Community Solid Server

        +
        +
        +

        Welcome to Solid

        +

        + This server implements + the Solid protocol + so you can create your own Solid Pod + and identity. +

        + +

        Getting started as a user

        +

        + Sign up for an account + to get started with your own Pod and WebID. +

        +

        + The default configuration stores data only in memory. + If you want to keep data permanently, + choose a configuration that saves data to disk instead. +

        +

        + To learn more about how this server can be used, + have a look at the + getting started tutorial. +

        + +

        Getting started as a developer

        +

        + The default configuration includes + the ready-to-use root Pod you're currently looking at. +
        + You can use any of the configurations in the config folder of the server + to set up an instance of this server with different features. + Besides the provided configurations, + you can also fine-tune your own custom configuration using the + configuration generator. +

        +

        + You can easily choose any folder on your disk + to expose as the root Pod with file-based configurations. +
        + Use the --help switch to learn more. +

        +

        + Due to certain restrictions in the Solid specification it is usually not allowed + to both allow data to be written to the root of the server, + and to enable the creation of new pods. + This configuration does allow both these options to allow a quick exploration of Solid, + but other configurations provided will only allow one of those two to be enabled. +

        + +

        Have a wonderful Solid experience

        +

        + Learn more about Solid + at solidproject.org. +

        +

        + You are warmly invited + to share your experiences + and to report any bugs you encounter. +

        +
        + + + + + diff --git a/templates/root/intro/wac/.acl b/templates/root/intro/wac/.acl new file mode 100644 index 000000000..9916219fb --- /dev/null +++ b/templates/root/intro/wac/.acl @@ -0,0 +1,21 @@ +# WARNING: DO NOT USE UNMODIFIED UNLESS FOR TESTING PURPOSES. +# WHEN IN DOUBT, DELETE THIS DOCUMENT. +# +# This root ACL resource allows unrestricted public access to all documents and subcontainers. +# +# This document was automatically generated by the Community Solid Server +# because the "Expose a public root Pod" option was selected during setup, +# or because setup has been bypassed. +# +# We strongly suggest to edit this document such that it restricts permissions. + +@prefix acl: . +@prefix foaf: . + +# Give all agents Read, Write, and Control permissions on everything +<#authorization> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:mode acl:Read, acl:Write, acl:Append, acl:Control; + acl:accessTo <./>; + acl:default <./>. diff --git a/templates/root/prefilled/base/index.html b/templates/root/prefilled/base/index.html index c116e399d..ae116e142 100644 --- a/templates/root/prefilled/base/index.html +++ b/templates/root/prefilled/base/index.html @@ -4,11 +4,11 @@ Community Solid Server - +
        - [Solid logo] + [Solid logo]

        Community Solid Server

        @@ -21,26 +21,25 @@

        Getting started as a user

        -

        - Sign up for an account +

        + Sign up for an account to get started with your own Pod and WebID.

        -

        - The default configuration stores data only in memory. - If you want to keep data permanently, - choose a configuration that saves data to disk instead. +

        To learn more about how this server can be used, have a look at the - getting started tutorial. + getting started tutorial + and the server documentation.

        Getting started as a developer

        - The default configuration includes - the ready-to-use root Pod you're currently looking at. -
        + You can use any of the configurations in the config folder of the server + to set up an instance of this server with different features. Besides the provided configurations, you can also fine-tune your own custom configuration using the configuration generator. @@ -55,7 +54,7 @@

        Have a wonderful Solid experience

        Learn more about Solid - at solidproject.org. + at solidproject.org.

        You are warmly invited @@ -70,4 +69,18 @@

        + diff --git a/templates/root/static/index.html b/templates/root/static/index.html index b6dc652bb..e3e964753 100644 --- a/templates/root/static/index.html +++ b/templates/root/static/index.html @@ -21,26 +21,25 @@

        Getting started as a user

        -

        - Sign up for an account +

        + Sign up for an account to get started with your own Pod and WebID.

        -

        - The default configuration stores data only in memory. - If you want to keep data permanently, - choose a configuration that saves data to disk instead. +

        To learn more about how this server can be used, have a look at the - getting started tutorial. + getting started tutorial + and the server documentation.

        Getting started as a developer

        - The default configuration includes - the ready-to-use root Pod you're currently looking at. -
        + You can use any of the configurations in the config folder of the server + to set up an of this server with different features. Besides the provided configurations, you can also fine-tune your own custom configuration using the configuration generator. @@ -62,6 +61,20 @@ to share your experiences and to report any bugs you encounter.

        + +

        Replacing this page

        +

        + This index page is a static page configured to be shown at the root of the server. + You can replace this page by adding the following entry to your server configuration: +

        {
        +  "@type": "Override",
        +  "overrideInstance": { "@id": "urn:solid-server:default:RootStaticAsset" },
        +  "overrideParameters": {
        +    "@type": "StaticAssetEntry",
        +    "filePath": "/path/to/my/index.html"
        +  }
        +}
        +

        @@ -70,4 +83,18 @@

        + diff --git a/templates/scripts/util.js b/templates/scripts/util.js index 117315be7..0d4a6f528 100644 --- a/templates/scripts/util.js +++ b/templates/scripts/util.js @@ -1,7 +1,7 @@ /** * Returns an object that maps IDs to the corresponding element. * - * @param ...ids - IDs of the element (empty to retrieve all elements) + * @param ids - IDs of the element (empty to retrieve all elements) */ function getElements(...ids) { ids = ids.length ? ids : [...document.querySelectorAll("[id]")].map(e => e.id); @@ -12,97 +12,57 @@ function getElements(...ids) { * Acquires all data from the given form and POSTs it as JSON to the target URL. * In case of failure this function will throw an error. * In case of success a parsed JSON body of the response will be returned, - * unless the body contains a `location` field, - * in that case the page will be redirected to that location. + * unless a redirect was expected, + * in which case a redirect will happen or an error will be thrown if there is no location field. * - * @param formId - ID of the form. * @param target - Target URL to POST to. Defaults to the current URL. - * @returns {Promise} - The response JSON. + * @param expectRedirect - If a redirect is expected. Defaults to `false`. + * @param transform - A function that gets as input a JSON representation of the form. The output will be POSTed. Defaults to identity function. + * @param formId - The ID of the form. Defaults to "mainForm". */ -async function postJsonForm(formId, target = '') { +async function postJsonForm(target = '', expectRedirect = false, transform = (json) => json, formId = 'mainForm') { const form = document.getElementById(formId); const formData = new FormData(form); - const res = await fetch(target, { - method: 'POST', - credentials: 'include', - headers: { 'accept': 'application/json', 'content-type': 'application/json' }, - body: JSON.stringify(Object.fromEntries(formData)), - }); + const json = transform(Object.fromEntries(formData)); + const res = await postJson(target, json); if (res.status >= 400) { const error = await res.json(); - throw new Error(`${error.statusCode} - ${error.name}: ${error.message}`) + throw new Error(error.message); } else if (res.status === 200 || res.status === 201) { const body = await res.json(); if (body.location) { location.href = body.location; } else { + if (expectRedirect) { + throw new Error('Expected a location field in the response.'); + } return body; } } } /** - * Redirects the page to the given target with the key/value pairs of the JSON body as query parameters. - * Controls will be deleted from the JSON to prevent very large URLs. - * `false` values will be deleted to prevent incorrect serializations to "false". - * @param json - JSON to convert. - * @param target - URL to redirect to. - */ -function redirectJsonResponse(json, target) { - // These would cause the URL to get very large, can be acquired later if needed - delete json.controls; - - // Remove false parameters since these would be converted to "false" strings - for (const [key, val] of Object.entries(json)) { - if (typeof val === 'boolean' && !val) { - delete json[key]; - } - } - - const searchParams = new URLSearchParams(Object.entries(json)); - location.href = `${target}?${searchParams.toString()}`; -} - -/** - * Adds a listener to the given form to catch the form submission and do an API call instead. - * In case of an error, the inner text of the given error block will be updated with the message. - * In case of success the callback function will be called. + * Adds a listener to the given form to prevent the default interaction and instead call the provided callback. + * In case of an error, it will be caught and the message will be shown in the error block. * - * @param formId - ID of the form. - * @param errorId - ID of the error block. - * @param apiTarget - Target URL to send the POST request to. Defaults to the current URL. - * @param callback - Callback function that will be called with the response JSON. + * @param callback - Callback to call. + * @param formId - ID of the form. Defaults to "mainForm". + * @param errorId - ID of the error block. Defaults to "error". */ -async function addPostListener(formId, errorId, apiTarget, callback) { +function addPostListener(callback, formId = 'mainForm', errorId = 'error') { const form = document.getElementById(formId); - const errorBlock = document.getElementById(errorId); form.addEventListener('submit', async(event) => { event.preventDefault(); try { - const json = await postJsonForm(formId, apiTarget); - if (json) { - callback(json); - } + await callback(); } catch (error) { - errorBlock.innerText = error.message; + setError(error.message, errorId); } }); } -/** - * Updates links on a page based on the controls received from the API. - * @param url - API URL that will return the controls - * @param controlMap - Key/value map with keys being element IDs and values being the control field names. - */ -async function addControlLinks(url, controlMap) { - const json = await fetchJson(url); - for (let [ id, control ] of Object.entries(controlMap)) { - updateElement(id, json.controls[control], { href: true }); - } -} - /** * Shows or hides the given element. * @param id - ID of the element. @@ -130,12 +90,13 @@ function getDescendants(element) { /** * Updates the inner text and href field of an element. * @param id - ID of the element. - * @param text - Text to put in the field(s). + * @param text - Text to put in the field(s). If this is undefined, instead the element will be hidden. * @param options - Indicates which fields should be updated. * Keys should be `innerText` and/or `href`, values should be booleans. */ function updateElement(id, text, options) { const element = document.getElementById(id); + setVisibility(id, Boolean(text)); if (options.innerText) { element.innerText = text; } @@ -147,8 +108,64 @@ function updateElement(id, text, options) { /** * Fetches JSON from the url and converts it to an object. * @param url - URL to fetch JSON from. + * @param redirectUrl - URL to redirect to in case the response code is >= 400. No redirect happens if undefined. */ -async function fetchJson(url) { +async function fetchJson(url, redirectUrl) { const res = await fetch(url, { headers: { accept: 'application/json' } }); + + if (redirectUrl && res.status >= 400) { + location.href = redirectUrl; + return; + } + return res.json(); } + +/** + * Returns the controls object that can be found accessing the given URL. + */ +async function fetchControls(url) { + return (await fetchJson(url)).controls; +} + +/** + * POSTs JSON to the given URL and returns the response. + */ +async function postJson(url, json) { + return fetch(url, { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify(json), + }); +} + +/** + * Sets the contents of the error block to the given error message. + * Default ID of the error block is `error`. + */ +function setError(message, errorId = 'error') { + updateElement(errorId, message, { innerText: true }); +} + +/** + * Causes the page to redirect to a specific page when a button is clicked. + * @param element - The id of the button. + * @param url - The URL to redirect to. + */ +function setRedirectClick(element, url) { + document.getElementById(element).addEventListener('click', () => location.href = url); +} + +/** + * Validates a password form to see if the confirmation password matches the password. + * + * @param passwordId - The id of the password field. + * @param formId - ID of the form. Defaults to "mainForm". + * @param confirmPasswordId - ID of the password confirmation field. Defaults to "confirmPassword". + */ +function validatePasswordConfirmation(passwordId, formId = 'mainForm', confirmPasswordId = 'confirmPassword') { + const formData = new FormData(document.getElementById(formId)); + if (formData.get(passwordId) !== formData.get(confirmPasswordId)) { + throw new Error('Password confirmation does not match the password!'); + } +} diff --git a/templates/styles/main.css b/templates/styles/main.css index 7e5e21ed4..951d68f4d 100644 --- a/templates/styles/main.css +++ b/templates/styles/main.css @@ -246,23 +246,23 @@ input:focus, button:focus { outline: var(--solid-blue) solid 1.5px; } -form p.actions { - margin: .5em 0 1em 11em; -} - -form p.error { +p.error { color: #ad0f0f; font-weight: 600; } -form p.error:empty { +p.error:empty { display :none; } -form ul.actions { +p.actions { + margin: .5em 0 1em 11em; +} + +ul.actions { padding: 0; margin: 0 0 1em 11em; } -form ul.actions > li { +ul.actions > li { list-style-type: none; display: inline; margin-right: 1em; @@ -278,7 +278,7 @@ form.loaded * { max-height: 1000px; transition: max-height .2s; } -form .hidden { +form .hidden:not(button) { display: block; max-height: 0; overflow: hidden; diff --git a/test/deploy/createAccountCredentials.ts b/test/deploy/createAccountCredentials.ts index ccd970076..17747b42f 100644 --- a/test/deploy/createAccountCredentials.ts +++ b/test/deploy/createAccountCredentials.ts @@ -28,37 +28,73 @@ const bob: User = { }; /** - * Registers a user with the server. + * Registers a user with the server and provides them with a pod. * @param user - The user settings necessary to register a user. */ -async function register(user: User): Promise { - const body = JSON.stringify({ - ...user, - confirmPassword: user.password, - createWebId: true, - register: true, - createPod: true, +async function register(user: User): Promise<{ webId: string; cookie: string }> { + // Get controls + let res = await fetch(urljoin(baseUrl, '.account/')); + let { controls } = await res.json(); + + // Create account + res = await fetch(controls.account.create, { method: 'POST' }); + if (res.status !== 200) { + throw new Error(`Account creation failed: ${await res.text()}`); + } + const { cookie } = await res.json(); + const authorization = `CSS-Account-Cookie ${cookie}`; + + // Get account controls + res = await fetch(controls.main.index, { + headers: { authorization }, }); - const res = await fetch(urljoin(baseUrl, '/idp/register/'), { + ({ controls } = await res.json()); + + // Add login method + res = await fetch(controls.password.create, { method: 'POST', - headers: { 'content-type': 'application/json' }, - body, + headers: { authorization, 'content-type': 'application/json' }, + body: JSON.stringify({ + email: user.email, + password: user.password, + }), }); if (res.status !== 200) { - throw new Error(`Registration failed: ${await res.text()}`); + throw new Error(`Login creation failed: ${await res.text()}`); } + + // Create pod + res = await fetch(controls.account.pod, { + method: 'POST', + headers: { authorization, 'content-type': 'application/json' }, + body: JSON.stringify({ name: user.podName }), + }); + if (res.status !== 200) { + throw new Error(`Pod creation failed: ${await res.text()}`); + } + const { webId } = await res.json(); + + return { webId, cookie }; } /** * Requests a client credentials API token. - * @param user - User for which the token needs to be generated. + * @param webId - WebID to create credentials for. + * @param cookie - Authoriziation cookie for the account that tries to create credentials. * @returns The id/secret for the client credentials request. */ -async function createCredentials(user: User): Promise<{ id: string; secret: string }> { - const res = await fetch(urljoin(baseUrl, '/idp/credentials/'), { +async function createCredentials(webId: string, cookie: string): Promise<{ id: string; secret: string }> { + // Get account controls + const authorization = `CSS-Account-Cookie ${cookie}`; + let res = await fetch(urljoin(baseUrl, '.account/'), { + headers: { authorization }, + }); + const { controls } = await res.json(); + + res = await fetch(controls.account.clientCredentials, { method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ email: user.email, password: user.password, name: 'token' }), + headers: { authorization, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'token', webId }), }); if (res.status !== 200) { throw new Error(`Token generation failed: ${await res.text()}`); @@ -74,8 +110,8 @@ async function createCredentials(user: User): Promise<{ id: string; secret: stri * @param user - User for which data needs to be generated. */ async function outputCredentials(user: User): Promise { - await register(user); - const { id, secret } = await createCredentials(user); + const { webId, cookie } = await register(user); + const { id, secret } = await createCredentials(webId, cookie); const name = user.podName.toUpperCase(); console.log(`USERS_${name}_CLIENTID=${id}`); diff --git a/test/integration/Accounts.test.ts b/test/integration/Accounts.test.ts new file mode 100644 index 000000000..64d7f437a --- /dev/null +++ b/test/integration/Accounts.test.ts @@ -0,0 +1,467 @@ +import fetch from 'cross-fetch'; +import { parse, splitCookiesString } from 'set-cookie-parser'; +import type { App } from '../../src/init/App'; +import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes'; +import { joinUrl } from '../../src/util/PathUtil'; +import { getPort } from '../util/Util'; +import { getDefaultVariables, getTestConfigPath, instantiateFromConfig } from './Config'; + +const port = getPort('Accounts'); +const baseUrl = `http://localhost:${port}/`; + +// Don't send actual e-mails +jest.mock('nodemailer'); + +describe('A server with account management', (): void => { + let app: App; + let sendMail: jest.Mock; + + let cookie: string; + const email = 'test@example.com'; + let password = 'secret'; + const indexUrl = joinUrl(baseUrl, '.account/'); + let controls: { + main: Record<'index' | 'logins', string>; + account: Record<'create' | 'account' | 'logout' | 'pod' | 'webId' | 'clientCredentials', string>; + password: Record<'login' | 'forgot' | 'create', string>; + }; + let passwordResource: string; + let pod: string; + let webId: string; + + beforeAll(async(): Promise => { + // Needs to happen before Components.js instantiation + sendMail = jest.fn(); + const nodemailer = jest.requireMock('nodemailer'); + Object.assign(nodemailer, { createTransport: (): any => ({ sendMail }) }); + + const instances = await instantiateFromConfig( + 'urn:solid-server:test:Instances', + getTestConfigPath('server-memory.json'), + getDefaultVariables(port, baseUrl), + ) as Record; + ({ app } = instances); + await app.start(); + + controls = { main: {}, account: {}, login: {}, password: {}} as any; + }); + + afterAll(async(): Promise => { + await app.stop(); + }); + + it('can get the general index.', async(): Promise => { + const res = await fetch(indexUrl); + expect(res.status).toBe(200); + const json = await res.json(); + + expect(json.controls.main.index).toBe(indexUrl); + expect(json.controls.main.logins).toBeDefined(); + controls.main = json.controls.main; + + expect(json.controls.account.create).toBeDefined(); + controls.account = json.controls.account; + + expect(json.controls.password.login).toBeDefined(); + expect(json.controls.password.forgot).toBeDefined(); + controls.password = json.controls.password; + + expect(json.controls.html).toBeDefined(); + expect(json.controls.html.main).toBeDefined(); + expect(json.controls.html.password).toBeDefined(); + }); + + it('can create an account.', async(): Promise => { + const res = await fetch(controls.account.create, { method: 'POST' }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.resource).toBeDefined(); + expect(res.headers.get('set-cookie')).toBeDefined(); + const cookies = parse(splitCookiesString(res.headers.get('set-cookie')!)); + expect(cookies).toHaveLength(1); + + cookie = `${cookies[0].name}=${cookies[0].value}`; + expect(json.cookie).toBe(cookies[0].value); + + controls.account.account = json.resource; + }); + + it('can access the account using the cookie.', async(): Promise => { + expect((await fetch(controls.account.account)).status).toBe(401); + const res = await fetch(controls.account.account, { headers: { cookie }}); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.controls.account.account).toEqual(controls.account.account); + expect(json.controls.account.logout).toBeDefined(); + expect(json.controls.account.pod).toBeDefined(); + expect(json.controls.account.webId).toBeDefined(); + expect(json.controls.account.clientCredentials).toBeDefined(); + + controls.account = json.controls.account; + + expect(json.controls.password.create).toBeDefined(); + controls.password = json.controls.password; + + expect(json.controls.html.account).toBeDefined(); + }); + + it('can also access the account using the custom authorization header.', async(): Promise => { + expect((await fetch(controls.account.account)).status).toBe(401); + const res = await fetch(controls.account.account, { headers: + { authorization: `CSS-Account-Cookie ${cookie.split('=')[1]}` }}); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.controls.account.account).toEqual(controls.account.account); + }); + + it('can not create a pod since the account has no login.', async(): Promise => { + const res = await fetch(controls.account.pod, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'test' }), + }); + expect(res.status).toBe(400); + expect((await res.json()).message).toBe('An account needs at least 1 login method.'); + }); + + it('can add a password login to the account.', async(): Promise => { + let res = await fetch(controls.password.create, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ + email, + password, + }), + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.resource).toBeDefined(); + ({ resource: passwordResource } = json); + + // Verify if the content was added to the profile + res = await fetch(controls.account.account, { headers: { cookie }}); + expect(res.status).toBe(200); + expect((await res.json()).logins.password).toEqual({ [email]: passwordResource }); + }); + + it('can not delete its last login method.', async(): Promise => { + const res = await fetch(passwordResource, { method: 'DELETE', headers: { cookie }}); + expect(res.status).toBe(400); + expect((await res.json()).message).toBe('An account needs at least 1 login method.'); + }); + + it('can not use the same email for a different account.', async(): Promise => { + let res = await fetch(controls.account.create, { method: 'POST' }); + const cookies = parse(splitCookiesString(res.headers.get('set-cookie')!)); + const newCookie = `${cookies[0].name}=${cookies[0].value}`; + const { resource } = await res.json(); + + res = await fetch(resource, { headers: { cookie: newCookie }}); + const oldAccount: { controls: typeof controls } = await res.json(); + + // This will fail because the email address is already used by a different account + res = await fetch(oldAccount.controls.password.create, { + method: 'POST', + headers: { cookie: newCookie, 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + expect(res.status).toBe(400); + + // Make sure the account still has no login method + res = await fetch(resource, { headers: { cookie: newCookie }}); + await expect(res.json()).resolves.toEqual(oldAccount); + }); + + it('can log out.', async(): Promise => { + const res = await fetch(controls.account.logout, { method: 'POST', headers: { cookie }}); + expect(res.status).toBe(200); + // Cookie doesn't work anymore + expect((await fetch(controls.account.account, { headers: { cookie }})).status).toBe(401); + }); + + it('can login again with email/password.', async(): Promise => { + const res = await fetch(controls.password.login, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + expect(res.status).toBe(200); + + const cookies = parse(splitCookiesString(res.headers.get('set-cookie')!)); + expect(cookies).toHaveLength(1); + cookie = `${cookies[0].name}=${cookies[0].value}`; + // Cookie is valid again + expect((await fetch(controls.account.account, { headers: { cookie }})).status).toBe(200); + }); + + it('can change the password.', async(): Promise => { + let res = await fetch(passwordResource, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ + oldPassword: password, + newPassword: 'secret2', + }), + }); + password = 'secret2'; + expect(res.status).toBe(200); + + // Check new password + res = await fetch(controls.password.login, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('set-cookie')).toBeDefined(); + }); + + it('can create a pod.', async(): Promise => { + let res = await fetch(controls.account.pod, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'test' }), + }); + expect(res.status).toBe(200); + let json = await res.json(); + expect(json.pod).toBeDefined(); + expect(json.podResource).toBeDefined(); + expect(json.webId).toBeDefined(); + expect(json.webIdResource).toBeDefined(); + ({ pod, webId } = json); + + // Verify if the content was added to the profile + res = await fetch(controls.account.account, { headers: { cookie }}); + expect(res.status).toBe(200); + json = await res.json(); + expect(json.pods[pod]).toBeDefined(); + expect(json.webIds[webId]).toBeDefined(); + }); + + it('does not store any data if creating a pod fails on the same account.', async(): Promise => { + let res = await fetch(controls.account.account, { headers: { cookie }}); + const oldAccount = await res.json(); + + res = await fetch(controls.account.pod, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'test' }), + }); + expect(res.status).toBe(400); + + // Verify nothing was added + res = await fetch(controls.account.account, { headers: { cookie }}); + await expect(res.json()).resolves.toEqual(oldAccount); + }); + + it('does not store any data if creating a pod fails on a different account.', async(): Promise => { + // We have to create a new account here to try to create a pod with the same name. + // Otherwise the server will never try to write data + // since it would notice the account already has a pod with that name. + let res = await fetch(controls.account.create, { method: 'POST' }); + const cookies = parse(splitCookiesString(res.headers.get('set-cookie')!)); + const newCookie = `${cookies[0].name}=${cookies[0].value}`; + res = await fetch(indexUrl, { headers: { cookie: newCookie }}); + const json: { controls: typeof controls } = await res.json(); + res = await fetch(json.controls.password.create, { + method: 'POST', + headers: { cookie: newCookie, 'content-type': 'application/json' }, + body: JSON.stringify({ + email: 'differentMail@example.com', + password, + }), + }); + expect(res.status).toBe(200); + + res = await fetch(json.controls.account.account, { headers: { cookie: newCookie }}); + const oldAccount = await res.json(); + + // This will fail because there already is a pod with this name + res = await fetch(json.controls.account.pod, { + method: 'POST', + headers: { cookie: newCookie, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'test' }), + }); + expect(res.status).toBe(400); + expect((await res.json()).message).toContain('Pod creation failed'); + + // Make sure there is no reference in the account data + res = await fetch(json.controls.account.account, { headers: { cookie: newCookie }}); + await expect(res.json()).resolves.toEqual(oldAccount); + }); + + it('can remove the WebID link.', async(): Promise => { + let res = await fetch(controls.account.account, { headers: { cookie }}); + const webIdResource = (await res.json()).webIds[webId]; + res = await fetch(webIdResource, { method: 'DELETE', headers: { cookie }}); + expect(res.status).toBe(200); + res = await fetch(controls.account.account, { headers: { cookie }}); + expect((await res.json()).webIds[webId]).toBeUndefined(); + }); + + it('can link the WebID again.', async(): Promise => { + let res = await fetch(controls.account.webId, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ webId }), + }); + expect(res.status).toBe(200); + let json = await res.json(); + expect(json.resource).toBeDefined(); + expect(json.oidcIssuer).toBe(baseUrl); + + // Verify if the content was added to the profile + res = await fetch(controls.account.account, { headers: { cookie }}); + expect(res.status).toBe(200); + json = await res.json(); + expect(json.webIds[webId]).toBeDefined(); + }); + + it('needs to prove ownership when linking a WebID outside of a pod.', async(): Promise => { + const otherWebId = joinUrl(baseUrl, 'other#me'); + // Create the WebID + let res = await fetch(otherWebId, { + method: 'PUT', + headers: { 'content-type': 'text/turtle' }, + body: '', + }); + expect(res.status).toBe(201); + + // Try to link the WebID + res = await fetch(controls.account.webId, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ webId: otherWebId }), + }); + expect(res.status).toBe(400); + let json = await res.json(); + expect(json.details?.quad).toBeDefined(); + const { quad } = json.details; + + // Update the WebID with the identifying quad + await fetch(otherWebId, { + method: 'PUT', + headers: { 'content-type': 'text/turtle' }, + body: quad, + }); + + // Try to link the WebID again + res = await fetch(controls.account.webId, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ webId: otherWebId }), + }); + expect(res.status).toBe(200); + + // Verify if the content was added to the profile + res = await fetch(controls.account.account, { headers: { cookie }}); + expect(res.status).toBe(200); + json = await res.json(); + // 2 linked WebIDs now + expect(json.webIds[webId]).toBeDefined(); + expect(json.webIds[otherWebId]).toBeDefined(); + }); + + it('can create a client credentials token.', async(): Promise => { + let res = await fetch(controls.account.clientCredentials, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'token', webId }), + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.id).toMatch(/^token/u); + expect(json.secret).toBeDefined(); + expect(json.resource).toBeDefined(); + const { id, resource, secret } = json; + + // Verify if the content was added to the profile + res = await fetch(controls.account.account, { headers: { cookie }}); + expect(res.status).toBe(200); + const { clientCredentials } = await res.json(); + expect(clientCredentials[id]).toBe(resource); + + // Request a token + const authString = `${encodeURIComponent(id)}:${encodeURIComponent(secret)}`; + res = await fetch(joinUrl(baseUrl, '.oidc/token'), { + method: 'POST', + headers: { + authorization: `Basic ${Buffer.from(authString).toString('base64')}`, + 'content-type': APPLICATION_X_WWW_FORM_URLENCODED, + }, + body: 'grant_type=client_credentials&scope=webid', + }); + expect(res.status).toBe(200); + const { access_token: token } = await res.json(); + expect(token).toBeDefined(); + }); + + it('can remove registered WebIDs.', async(): Promise => { + let res = await fetch(controls.account.account, { headers: { cookie }}); + expect(res.status).toBe(200); + let json = await res.json(); + + res = await fetch(json.webIds[webId], { method: 'DELETE', headers: { cookie }}); + expect(res.status).toBe(200); + + // Make sure it's gone + res = await fetch(controls.account.account, { headers: { cookie }}); + json = await res.json(); + expect(json.webIds[webId]).toBeUndefined(); + }); + + it('can remove credential tokens.', async(): Promise => { + let res = await fetch(controls.account.account, { headers: { cookie }}); + expect(res.status).toBe(200); + let json = await res.json(); + + const tokenUrl = Object.values(json.clientCredentials)[0] as string; + res = await fetch(tokenUrl, { method: 'DELETE', headers: { cookie }}); + expect(res.status).toBe(200); + + // Make sure it's gone + res = await fetch(controls.account.account, { headers: { cookie }}); + json = await res.json(); + expect(Object.keys(json.clientCredentials)).toHaveLength(0); + }); + + it('can reset a password if forgotten.', async(): Promise => { + let res = await fetch(controls.password.forgot, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email }), + }); + expect(res.status).toBe(200); + + expect(sendMail).toHaveBeenCalledTimes(1); + + // Parse reset URL out of mail + const mail = sendMail.mock.calls[0][0]; + expect(mail.to).toBe(email); + const match = /(http:.*)$/u.exec(mail.text); + expect(match).toBeDefined(); + const resetUrl = match![1]; + res = await fetch(resetUrl); + const url = new URL(resetUrl); + const recordId = url.searchParams.get('rid'); + expect(recordId).toBeDefined(); + + // Reset the password + password = 'resetSecret'; + res = await fetch(resetUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ recordId, password }), + }); + expect(res.status).toBe(200); + + // Verify logging in with the new password works + res = await fetch(controls.password.login, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('set-cookie')).toBeDefined(); + }); +}); diff --git a/test/integration/Config.ts b/test/integration/Config.ts index 6542e0040..71a2d15c8 100644 --- a/test/integration/Config.ts +++ b/test/integration/Config.ts @@ -54,7 +54,7 @@ export function getDefaultVariables(port: number, baseUrl?: string): Record => removeFolder(rootFilePath), }], [ 'filesystem.json', { @@ -23,16 +26,18 @@ const configs: [string, any][] = [ // Tests are very similar to subdomain/pod tests. Would be nice if they can be combined describe.each(configs)('A dynamic pod server with template config %s', (template, { teardown }): void => { let app: App; - const settings = { + const user: User = { podName: 'alice', - webId: 'http://test.com/#alice', + webId: 'http://example.com/#alice', email: 'alice@test.email', password: 'password', - confirmPassword: 'password', - template, - createPod: true, + settings: { + template, + }, }; - const podUrl = `${baseUrl}${settings.podName}/`; + const podUrl = `${baseUrl}${user.podName}/`; + let controls: any; + let authorization: string; beforeAll(async(): Promise => { const variables: Record = { @@ -61,13 +66,9 @@ describe.each(configs)('A dynamic pod server with template config %s', (template }); it('creates a pod with the given config.', async(): Promise => { - const res = await fetch(`${baseUrl}idp/register/`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(settings), - }); - expect(res.status).toBe(200); - await expect(res.text()).resolves.toContain(podUrl); + const result = await register(baseUrl, user); + ({ controls, authorization } = result); + expect(result.pod).toBe(podUrl); }); it('can fetch the created pod.', async(): Promise => { @@ -83,7 +84,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template it('should be able to read acl file with the correct credentials.', async(): Promise => { const res = await fetch(`${podUrl}.acl`, { headers: { - authorization: `WebID ${settings.webId}`, + authorization: `WebID ${user.webId}`, }, }); expect(res.status).toBe(200); @@ -92,7 +93,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template it('should be able to write to the pod now as the owner.', async(): Promise => { let res = await fetch(`${podUrl}test`, { headers: { - authorization: `WebID ${settings.webId}`, + authorization: `WebID ${user.webId}`, }, }); expect(res.status).toBe(404); @@ -100,7 +101,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template res = await fetch(`${podUrl}test`, { method: 'PUT', headers: { - authorization: `WebID ${settings.webId}`, + authorization: `WebID ${user.webId}`, 'content-type': 'text/plain', }, body: 'this is new data!', @@ -110,7 +111,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template res = await fetch(`${podUrl}test`, { headers: { - authorization: `WebID ${settings.webId}`, + authorization: `WebID ${user.webId}`, }, }); expect(res.status).toBe(200); @@ -118,13 +119,12 @@ describe.each(configs)('A dynamic pod server with template config %s', (template }); it('should not be able to create a pod with the same name.', async(): Promise => { - const newSettings = { ...settings, webId: 'http://test.com/#bob', email: 'bob@test.email' }; - const res = await fetch(`${baseUrl}idp/register/`, { + const res = await fetch(controls.account.pod, { method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(newSettings), + headers: { authorization, 'content-type': 'application/json' }, + body: JSON.stringify({ name: user.podName, settings: { template }}), }); - expect(res.status).toBe(409); + expect(res.status).toBe(400); await expect(res.text()).resolves.toContain(`There already is a pod at ${podUrl}`); }); }); diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts index 8450e204f..3bac27581 100644 --- a/test/integration/Identity.test.ts +++ b/test/integration/Identity.test.ts @@ -1,17 +1,15 @@ -import { stringify } from 'querystring'; -import { URL } from 'url'; import type { KeyPair } from '@inrupt/solid-client-authn-core'; import { buildAuthenticatedFetch, createDpopHeader, generateDpopKeyPair, } from '@inrupt/solid-client-authn-core'; -import { load } from 'cheerio'; -import type { Response } from 'cross-fetch'; import { fetch } from 'cross-fetch'; +import { parse, splitCookiesString } from 'set-cookie-parser'; import type { App } from '../../src/init/App'; -import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes'; +import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes'; import { joinUrl } from '../../src/util/PathUtil'; +import { register } from '../util/AccountUtil'; import { getPort } from '../util/Util'; import { getDefaultVariables, getTestConfigPath, getTestFolder, instantiateFromConfig, removeFolder } from './Config'; import { IdentityTestState } from './IdentityTestState'; @@ -31,20 +29,9 @@ const stores: [string, any][] = [ }], ]; -// Don't send actual e-mails -jest.mock('nodemailer'); - // Prevent panva/node-openid-client from emitting DraftWarning jest.spyOn(process, 'emitWarning').mockImplementation(); -async function postForm(url: string, formBody: string): Promise { - return fetch(url, { - method: 'POST', - headers: { 'content-type': APPLICATION_X_WWW_FORM_URLENCODED }, - body: formBody, - }); -} - // No way around the cookies https://github.com/panva/node-oidc-provider/issues/552 . // They will be simulated by storing the values and passing them along. // This is why the redirects are handled manually. @@ -54,23 +41,20 @@ describe.each(stores)('A Solid server with IDP using %s', (name, { config, teard const redirectUrl = 'http://mockedredirect/'; const container = new URL('secret/', baseUrl).href; const oidcIssuer = baseUrl; - const card = joinUrl(baseUrl, 'profile/card'); - const webId = `${card}#me`; - const webId2 = `${card}#someoneElse`; - let webId3: string; - const email = 'test@test.com'; - const email2 = 'bob@test.email'; - const email3 = 'alice@test.email'; + const indexUrl = joinUrl(baseUrl, '.account/'); + let webId: string; + let webId2: string; + const email = 'test@example.com'; + const email2 = 'otherMail@example.com'; const password = 'password!'; - const password2 = 'password2!'; - let sendMail: jest.Mock; + let controls: { + oidc: { webId: string; consent: string; forgetWebId: string; prompt: string }; + main: { index: string }; + account: { create: string; pod: string; logout: string }; + password: { create: string; login: string }; + }; beforeAll(async(): Promise => { - // Needs to happen before Components.js instantiation - sendMail = jest.fn(); - const nodemailer = jest.requireMock('nodemailer'); - Object.assign(nodemailer, { createTransport: (): any => ({ sendMail }) }); - const instances = await instantiateFromConfig( 'urn:solid-server:test:Instances', getTestConfigPath(config), @@ -82,13 +66,9 @@ describe.each(stores)('A Solid server with IDP using %s', (name, { config, teard ({ app } = instances); await app.start(); - // Create a simple webId - const webIdTurtle = `<${webId}> <${baseUrl}> .`; - await fetch(card, { - method: 'PUT', - headers: { 'content-type': 'text/turtle' }, - body: webIdTurtle, - }); + // Create accounts + ({ webId, controls } = await register(baseUrl, { email, password, podName: 'test' })); + ({ webId: webId2 } = await register(baseUrl, { email: email2, password, podName: 'otherTest' })); // Create container where only webId can write const aclTurtle = ` @@ -114,43 +94,6 @@ describe.each(stores)('A Solid server with IDP using %s', (name, { config, teard await app.stop(); }); - describe('doing registration', (): void => { - let formBody: string; - let registrationTriple: string; - - beforeAll(async(): Promise => { - // We will need this twice - formBody = stringify({ email, webId, password, confirmPassword: password, register: 'ok' }); - }); - - it('sends the form once to receive the registration triple.', async(): Promise => { - const res = await postForm(`${baseUrl}idp/register/`, formBody); - expect(res.status).toBe(400); - const json = await res.json(); - registrationTriple = json.details.quad; - }); - - it('updates the webId with the registration token.', async(): Promise => { - const patchBody = `INSERT DATA { ${registrationTriple} }`; - const res = await fetch(webId, { - method: 'PATCH', - headers: { 'content-type': 'application/sparql-update' }, - body: patchBody, - }); - expect(res.status).toBe(205); - }); - - it('sends the form again to successfully register.', async(): Promise => { - const res = await postForm(`${baseUrl}idp/register/`, formBody); - expect(res.status).toBe(200); - await expect(res.json()).resolves.toEqual(expect.objectContaining({ - webId, - email, - oidcIssuer: baseUrl, - })); - }); - }); - describe('authenticating', (): void => { let state: IdentityTestState; @@ -162,16 +105,64 @@ describe.each(stores)('A Solid server with IDP using %s', (name, { config, teard await state.session.logout(); }); - it('initializes the session and logs in.', async(): Promise => { - let url = await state.startSession(); + it('initializes the session.', async(): Promise => { + // This is the auth URL with all the relevant query parameters + let url = await state.initSession(); + expect(url.startsWith(oidcIssuer)).toBeTruthy(); + + // Always redirect to our index page + url = await state.handleRedirect(url); + + // Compare received URL with login URL in our controls + expect(controls.main.index).toBe(url); + + // Add the OIDC controls to the object const res = await state.fetchIdp(url); - expect(res.status).toBe(200); - url = await state.login(url, email, password); - await state.consent(url); - expect(state.session.info?.webId).toBe(webId); + controls = { + ...(await res.json()).controls, + ...controls, + }; }); - it('can only access the container when using the logged in session.', async(): Promise => { + it('logs in.', async(): Promise => { + // Log in using email/password + const res = await state.fetchIdp(controls.password.login, 'POST', JSON.stringify({ email, password })); + + // Redirect to WebID picker + await expect(state.handleLocationRedirect(res)).resolves.toBe(indexUrl); + }); + + it('sends a token for the chosen WebID.', async(): Promise => { + // See the available WebIDs + let res = await state.fetchIdp(controls.oidc.webId); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.webIds).toEqual([ webId ]); + + // Pick the WebID + // Errors if the WebID is not registered to the account + res = await state.fetchIdp(controls.oidc.webId, 'POST', { webId: 'http://example.com/wrong' }); + expect(res.status).toBe(400); + res = await state.fetchIdp(controls.oidc.webId, 'POST', { webId, remember: true }); + + // Redirect to the consent page + await expect(state.handleLocationRedirect(res)).resolves.toBe(indexUrl); + }); + + it('consents and redirects back to the client.', async(): Promise => { + let res = await state.fetchIdp(controls.oidc.consent); + const json = await res.json(); + expect(json.webId).toBe(webId); + expect(json.client.grant_types).toContain('authorization_code'); + expect(json.client.grant_types).toContain('refresh_token'); + + res = await state.fetchIdp(controls.oidc.consent, 'POST'); + + // Redirect back to the client and verify login success + await state.handleIncomingRedirect(res, webId); + }); + + it('can only access the profile container when using the logged in session.', async(): Promise => { let res = await fetch(container); expect(res.status).toBe(401); @@ -185,16 +176,61 @@ describe.each(stores)('A Solid server with IDP using %s', (name, { config, teard expect(res.status).toBe(401); }); - it('can log in again.', async(): Promise => { - const url = await state.startSession(); + it('immediately gets redirect to the consent page in the next session.', async(): Promise => { + const url = await state.initSession(); + await state.handleRedirect(url); - const res = await state.fetchIdp(url); + const res = await state.fetchIdp(controls.oidc.prompt); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual(expect.objectContaining({ prompt: 'consent' })); + }); + + it('can forget the stored WebID.', async(): Promise => { + let res = await state.fetchIdp(controls.oidc.forgetWebId, 'POST'); expect(res.status).toBe(200); - // Will receive confirm screen here instead of login screen - await state.consent(url); + // We have to pick a WebID again + await expect(state.handleLocationRedirect(res)).resolves.toBe(indexUrl); + res = await state.fetchIdp(controls.oidc.webId, 'POST', { webId }); - expect(state.session.info?.webId).toBe(webId); + // Redirect back to the consent page + await expect(state.handleLocationRedirect(res)).resolves.toBe(indexUrl); + }); + + it('can consent again.', async(): Promise => { + let res = await state.fetchIdp(controls.oidc.consent, 'POST'); + + // Redirect back to the client and verify login success + await state.handleIncomingRedirect(res, webId); + + // Verify by accessing the private container + res = await state.session.fetch(container); + expect(res.status).toBe(200); + }); + + it('can log out.', async(): Promise => { + // Log out + let res = await state.fetchIdp(controls.account.logout, 'POST'); + expect(res.status).toBe(200); + + // Log out of the previous session and start a new one + await state.session.logout(); + const url = await state.initSession(); + await state.handleRedirect(url); + + // Log in + res = await state.fetchIdp(controls.password.login, 'POST', { email: email2, password }); + await expect(state.handleLocationRedirect(res)).resolves.toBe(indexUrl); + + // Pick the new WebID + res = await state.fetchIdp(controls.oidc.webId, 'POST', { webId: webId2 }); + await expect(state.handleLocationRedirect(res)).resolves.toBe(indexUrl); + + // Consent again + res = await state.fetchIdp(controls.oidc.consent, 'POST'); + + // Redirect back to the client and verify login success + await state.handleIncomingRedirect(res, webId2); }); }); @@ -248,20 +284,37 @@ describe.each(stores)('A Solid server with IDP using %s', (name, { config, teard }); it('initializes the session and logs in.', async(): Promise => { - let url = await state.startSession(clientId); - const res = await state.fetchIdp(url); - expect(res.status).toBe(200); - url = await state.login(url, email, password); + const url = await state.initSession(clientId); + + // Redirect to our login page + await state.handleRedirect(url); + + // Log in using email/password + let res = await state.fetchIdp(controls.password.login, 'POST', { email, password }); + + // Redirect to WebID picker + await expect(state.handleLocationRedirect(res)).resolves.toBe(indexUrl); + + // Pick the WebID + res = await state.fetchIdp(controls.oidc.webId, 'POST', { webId, remember: true }); + + // Redirect to the consent page + await expect(state.handleLocationRedirect(res)).resolves.toBe(indexUrl); // Verify the client information the server discovered - const consentRes = await state.fetchIdp(url, 'GET'); - expect(consentRes.status).toBe(200); - const { client } = await consentRes.json(); - expect(client.client_id).toBe(clientJson.client_id); - expect(client.client_name).toBe(clientJson.client_name); + res = await state.fetchIdp(controls.oidc.consent); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.webId).toBe(webId); + expect(json.client.client_id).toBe(clientJson.client_id); + expect(json.client.client_name).toBe(clientJson.client_name); + expect(json.client.grant_types).toContain('authorization_code'); + expect(json.client.grant_types).toContain('refresh_token'); - await state.consent(url); - expect(state.session.info?.webId).toBe(webId); + res = await state.fetchIdp(controls.oidc.consent, 'POST'); + + // Redirect back to the client and verify login success + await state.handleIncomingRedirect(res, webId); }); it('rejects requests in case the redirect URL is not accepted.', async(): Promise => { @@ -288,7 +341,6 @@ describe.each(stores)('A Solid server with IDP using %s', (name, { config, teard }); describe('using client_credentials', (): void => { - const credentialsUrl = joinUrl(baseUrl, '/idp/credentials/'); const tokenUrl = joinUrl(baseUrl, '.oidc/token'); let dpopKey: KeyPair; let id: string | undefined; @@ -300,18 +352,26 @@ describe.each(stores)('A Solid server with IDP using %s', (name, { config, teard }); it('can request a credentials token.', async(): Promise => { + // Login and save cookie + const loginResponse = await fetch(controls.password.login, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const cookies = parse(splitCookiesString(loginResponse.headers.get('set-cookie')!)); + const cookie = `${cookies[0].name}=${cookies[0].value}`; + + // Request token + const accountJson = await (await fetch(indexUrl, { headers: { cookie }})).json(); + const credentialsUrl = accountJson.controls.account.clientCredentials; const res = await fetch(credentialsUrl, { method: 'POST', - headers: { - 'content-type': APPLICATION_JSON, - }, - body: JSON.stringify({ email, password, name: 'token' }), + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'token', webId }), }); + expect(res.status).toBe(200); ({ id, secret } = await res.json()); - expect(typeof id).toBe('string'); - expect(typeof secret).toBe('string'); - expect(id).toMatch(/^token/u); }); it('can request an access token using the credentials.', async(): Promise => { @@ -339,288 +399,6 @@ describe.each(stores)('A Solid server with IDP using %s', (name, { config, teard res = await authFetch(container); expect(res.status).toBe(200); }); - - it('can see all credentials.', async(): Promise => { - const res = await fetch(credentialsUrl, { - method: 'POST', - headers: { - 'content-type': APPLICATION_JSON, - }, - body: JSON.stringify({ email, password }), - }); - expect(res.status).toBe(200); - await expect(res.json()).resolves.toEqual([ id ]); - }); - - it('can delete credentials.', async(): Promise => { - let res = await fetch(credentialsUrl, { - method: 'POST', - headers: { - 'content-type': APPLICATION_JSON, - }, - body: JSON.stringify({ email, password, delete: id }), - }); - expect(res.status).toBe(200); - - // Client_credentials call should fail now - const dpopHeader = await createDpopHeader(tokenUrl, 'POST', dpopKey); - const authString = `${encodeURIComponent(id!)}:${encodeURIComponent(secret!)}`; - res = await fetch(tokenUrl, { - method: 'POST', - headers: { - authorization: `Basic ${Buffer.from(authString).toString('base64')}`, - 'content-type': APPLICATION_X_WWW_FORM_URLENCODED, - dpop: dpopHeader, - }, - body: 'grant_type=client_credentials&scope=webid', - }); - expect(res.status).toBe(401); - }); - }); - - describe('resetting password', (): void => { - let nextUrl: string; - - it('sends the corresponding email address through the form to get a mail.', async(): Promise => { - const res = await postForm(`${baseUrl}idp/forgotpassword/`, stringify({ email })); - expect(res.status).toBe(200); - const json = await res.json(); - expect(json.email).toBe(email); - - const mail = sendMail.mock.calls[0][0]; - expect(mail.to).toBe(email); - const match = /(http:.*)$/u.exec(mail.text); - expect(match).toBeDefined(); - nextUrl = match![1]; - expect(nextUrl).toMatch(/\/resetpassword\/[^/]+$/u); - }); - - it('resets the password through the given link.', async(): Promise => { - // Extract the submit URL from the reset password form - let res = await fetch(nextUrl); - expect(res.status).toBe(200); - const text = await res.text(); - const relative = load(text)('form').attr('action'); - // Reset password form has no action causing the current URL to be used - expect(relative).toBeUndefined(); - - // Extract recordId from URL since JS is used to add it - const recordId = /\?rid=([^/]+)$/u.exec(nextUrl)?.[1]; - expect(typeof recordId).toBe('string'); - - // POST the new password to the same URL - const formData = stringify({ password: password2, confirmPassword: password2, recordId }); - res = await fetch(nextUrl, { - method: 'POST', - headers: { 'content-type': APPLICATION_X_WWW_FORM_URLENCODED }, - body: formData, - }); - expect(res.status).toBe(200); - }); - }); - - describe('logging in after password reset', (): void => { - let state: IdentityTestState; - let nextUrl: string; - - beforeAll(async(): Promise => { - state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); - }); - - afterAll(async(): Promise => { - await state.session.logout(); - }); - - it('can not log in with the old password anymore.', async(): Promise => { - const url = await state.startSession(); - nextUrl = url; - let res = await state.fetchIdp(url); - expect(res.status).toBe(200); - const formData = stringify({ email, password }); - res = await state.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); - expect(res.status).toBe(500); - expect(await res.text()).toContain('Incorrect password'); - }); - - it('can log in with the new password.', async(): Promise => { - const url = await state.login(nextUrl, email, password2); - await state.consent(url); - expect(state.session.info?.webId).toBe(webId); - }); - }); - - describe('creating pods without registering with the IDP', (): void => { - let formBody: string; - let registrationTriple: string; - const podName = 'myPod'; - - beforeAll(async(): Promise => { - // We will need this twice - formBody = stringify({ - email: email2, - webId: webId2, - password, - confirmPassword: password, - podName, - createPod: 'ok', - }); - }); - - it('sends the form once to receive the registration triple.', async(): Promise => { - const res = await postForm(`${baseUrl}idp/register/`, formBody); - expect(res.status).toBe(400); - const json = await res.json(); - registrationTriple = json.details.quad; - }); - - it('updates the webId with the registration token.', async(): Promise => { - const patchBody = `INSERT DATA { ${registrationTriple} }`; - const res = await fetch(webId2, { - method: 'PATCH', - headers: { 'content-type': 'application/sparql-update' }, - body: patchBody, - }); - expect(res.status).toBe(205); - }); - - it('sends the form again to successfully register.', async(): Promise => { - const res = await postForm(`${baseUrl}idp/register/`, formBody); - expect(res.status).toBe(200); - await expect(res.json()).resolves.toEqual(expect.objectContaining({ - email: email2, - webId: webId2, - podBaseUrl: `${baseUrl}${podName}/`, - })); - }); - }); - - describe('creating a new WebID', (): void => { - const podName = 'alice'; - let state: IdentityTestState; - - const formBody = stringify({ - email: email3, password, confirmPassword: password, podName, createWebId: 'ok', register: 'ok', createPod: 'ok', - }); - - afterAll(async(): Promise => { - await state.session.logout(); - }); - - it('sends the form to create the WebID and register.', async(): Promise => { - const res = await postForm(`${baseUrl}idp/register/`, formBody); - expect(res.status).toBe(200); - const json = await res.json(); - expect(json).toEqual(expect.objectContaining({ - webId: expect.any(String), - email: email3, - oidcIssuer: baseUrl, - podBaseUrl: `${baseUrl}${podName}/`, - })); - webId3 = json.webId; - }); - - it('initializes the session and logs in.', async(): Promise => { - state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); - let url = await state.startSession(); - const res = await state.fetchIdp(url); - expect(res.status).toBe(200); - url = await state.login(url, email3, password); - await state.consent(url); - expect(state.session.info?.webId).toBe(webId3); - }); - - it('can only write to the new profile when using the logged in session.', async(): Promise => { - const patchOptions = { - method: 'PATCH', - headers: { 'content-type': 'application/sparql-update' }, - body: `INSERT DATA { <> "A cool WebID." }`, - }; - - let res = await fetch(webId3, patchOptions); - expect(res.status).toBe(401); - - res = await state.session.fetch(webId3, patchOptions); - expect(res.status).toBe(205); - }); - - it('always has control over data in the pod.', async(): Promise => { - const podBaseUrl = `${baseUrl}${podName}/`; - const brokenAcl = '<#authorization> a .'; - - // Make the acl file unusable - let res = await state.session.fetch(`${podBaseUrl}.acl`, { - method: 'PUT', - headers: { 'content-type': 'text/turtle' }, - body: brokenAcl, - }); - expect(res.status).toBe(205); - - // The owner is locked out of their own pod due to a faulty acl file - res = await state.session.fetch(podBaseUrl); - expect(res.status).toBe(403); - - const fixedAcl = `@prefix acl: . -@prefix foaf: . - -<#authorization> - a acl:Authorization; - acl:agentClass foaf:Agent; - acl:mode acl:Read; - acl:accessTo <./>.`; - // Owner can still update the acl - res = await state.session.fetch(`${podBaseUrl}.acl`, { - method: 'PUT', - headers: { 'content-type': 'text/turtle' }, - body: fixedAcl, - }); - expect(res.status).toBe(205); - - // Access is possible again - res = await state.session.fetch(podBaseUrl); - expect(res.status).toBe(200); - }); - }); - - describe('having multiple accounts', (): void => { - let state: IdentityTestState; - let url: string; - - beforeAll(async(): Promise => { - state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer); - }); - - afterAll(async(): Promise => { - await state.session.logout(); - }); - - it('initializes the session and logs in with the first account.', async(): Promise => { - url = await state.startSession(); - const res = await state.fetchIdp(url); - expect(res.status).toBe(200); - url = await state.login(url, email, password2); - await state.consent(url); - expect(state.session.info?.webId).toBe(webId); - }); - - it('can log out on the consent page.', async(): Promise => { - await state.session.logout(); - - url = await state.startSession(); - - const res = await state.fetchIdp(url); - expect(res.status).toBe(200); - - // Will receive confirm screen here instead of login screen - url = await state.logout(url); - }); - - it('can log in with a different account.', async(): Promise => { - const res = await state.fetchIdp(url); - expect(res.status).toBe(200); - url = await state.login(url, email3, password); - await state.consent(url); - expect(state.session.info?.webId).toBe(webId3); - }); }); describe('setup', (): void => { diff --git a/test/integration/IdentityTestState.ts b/test/integration/IdentityTestState.ts index dd2f1de33..594baff37 100644 --- a/test/integration/IdentityTestState.ts +++ b/test/integration/IdentityTestState.ts @@ -1,12 +1,8 @@ -import { stringify } from 'querystring'; -import { URL } from 'url'; import { Session } from '@inrupt/solid-client-authn-node'; -import { load } from 'cheerio'; import type { Response } from 'cross-fetch'; import { fetch } from 'cross-fetch'; import type { Cookie } from 'set-cookie-parser'; import { parse, splitCookiesString } from 'set-cookie-parser'; -import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes'; /* eslint-disable jest/no-standalone-expect */ /** @@ -34,11 +30,17 @@ export class IdentityTestState { * Performs a fetch call while keeping track of the stored cookies and preventing redirects. * @param url - URL to call. * @param method - Method to use. - * @param body - Body to send along. - * @param contentType - Content-Type of the body. + * @param body - Body to send along. If this is not a string it will be JSONified. + * @param contentType - Content-Type of the body. If not defined but there is a body, this will be set to JSON. */ - public async fetchIdp(url: string, method = 'GET', body?: string, contentType?: string): Promise { + public async fetchIdp(url: string, method = 'GET', body?: string | unknown, contentType?: string): Promise { const options = { method, headers: { cookie: this.cookie }, body, redirect: 'manual' } as any; + if (body && typeof body !== 'string') { + options.body = JSON.stringify(body); + } + if (body && !contentType) { + contentType = 'application/json'; + } if (contentType) { options.headers['content-type'] = contentType; } @@ -58,24 +60,11 @@ export class IdentityTestState { } /** - * Uses the given jquery command to find a node in the given html body. - * The value from the given attribute field then gets extracted and combined with the base url. - * @param html - Body to parse. - * @param jquery - Query to run on the body. - * @param attr - Attribute to extract. + * Initializes the OIDC session for the given clientId. + * If undefined, dynamic registration will be used. */ - public extractUrl(html: string, jquery: string, attr: string): string { - const url = load(html)(jquery).attr(attr); - expect(typeof url).toBe('string'); - return new URL(url!, this.baseUrl).href; - } - - /** - * Initializes an authentication session and stores the relevant cookies for later re-use. - * All te relevant links from the login page get extracted. - */ - public async startSession(clientId?: string): Promise { - let nextUrl = ''; + public async initSession(clientId?: string): Promise { + let nextUrl: string; await this.session.login({ redirectUrl: this.redirectUrl, oidcIssuer: this.oidcIssuer, @@ -84,67 +73,43 @@ export class IdentityTestState { nextUrl = data; }, }); - expect(nextUrl.length > 0).toBeTruthy(); - expect(nextUrl.startsWith(this.oidcIssuer)).toBeTruthy(); - - // Need to catch the redirect so we can copy the cookies - let res = await this.fetchIdp(nextUrl); - expect(res.status).toBe(303); - nextUrl = res.headers.get('location')!; - - // Handle redirect - res = await this.fetchIdp(nextUrl); - expect(res.status).toBe(200); - - // Need to send request to prompt API to get actual location - let json = await res.json(); - res = await this.fetchIdp(json.controls.prompt); - json = await res.json(); - nextUrl = json.location; - - return nextUrl; + return nextUrl!; } /** - * Logs in by sending the corresponding email and password to the given form action. - * The URL should be extracted from the login page. + * Handles a URL that is expected to redirect and returns the target it would redirect to. */ - public async login(url: string, email: string, password: string): Promise { - const formData = stringify({ email, password }); - let res = await this.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED); - expect(res.status).toBe(200); - const json = await res.json(); - res = await this.fetchIdp(json.location); + public async handleRedirect(url: string): Promise { + const res = await this.fetchIdp(url); expect(res.status).toBe(303); + expect(res.headers.has('location')).toBe(true); return res.headers.get('location')!; } /** - * Handles the consent screen at the given URL and the followup redirect back to the client. + * Handles a JSON redirect. That is a request that returns a 200, + * but has a `location` field in the JSON to indicate what it should redirect to. + * That URL is expected to be another redirect, and this returns what it would redirect to. */ - public async consent(url: string): Promise { - let res = await this.fetchIdp(url, 'POST', '', APPLICATION_X_WWW_FORM_URLENCODED); + public async handleLocationRedirect(res: Response): Promise { expect(res.status).toBe(200); const json = await res.json(); + // The OIDC redirect + expect(json.location).toBeDefined(); - res = await this.fetchIdp(json.location); - expect(res.status).toBe(303); - const mockUrl = res.headers.get('location')!; - expect(mockUrl.startsWith(this.redirectUrl)).toBeTruthy(); + return this.handleRedirect(json.location); + } + + public async handleIncomingRedirect(res: Response, webId: string): Promise { + // Redirect back to the client + const url = await this.handleLocationRedirect(res); + expect(url.startsWith(this.redirectUrl)).toBe(true); // Workaround for https://github.com/inrupt/solid-client-authn-js/issues/2985 - const strippedUrl = new URL(mockUrl); + const strippedUrl = new URL(url); strippedUrl.searchParams.delete('iss'); const info = await this.session.handleIncomingRedirect(strippedUrl.href); expect(info?.isLoggedIn).toBe(true); - } - - public async logout(url: string): Promise { - let res = await this.fetchIdp(url, 'POST', stringify({ logOut: true }), APPLICATION_X_WWW_FORM_URLENCODED); - expect(res.status).toBe(200); - const json = await res.json(); - res = await this.fetchIdp(json.location); - expect(res.status).toBe(303); - return res.headers.get('location')!; + expect(info?.webId).toBe(webId); } } diff --git a/test/integration/Quota.test.ts b/test/integration/Quota.test.ts index 518c493c5..653d57d14 100644 --- a/test/integration/Quota.test.ts +++ b/test/integration/Quota.test.ts @@ -4,6 +4,7 @@ import type { Response } from 'cross-fetch'; import { ensureDir, pathExists, stat } from 'fs-extra'; import { joinUrl } from '../../src'; import type { App } from '../../src'; +import { register } from '../util/AccountUtil'; import { getPort } from '../util/Util'; import { getDefaultVariables, getTestConfigPath, getTestFolder, instantiateFromConfig, removeFolder } from './Config'; @@ -24,23 +25,7 @@ async function performSimplePutWithLength(path: string, length: number): Promise /** Registers two test pods on the server matching the 'baseUrl' */ async function registerTestPods(baseUrl: string, pods: string[]): Promise { for (const pod of pods) { - await fetch(`${baseUrl}idp/register/`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - createWebId: 'on', - webId: '', - register: 'on', - createPod: 'on', - podName: pod, - email: `${pod}@example.ai`, - password: 't', - confirmPassword: 't', - submit: '', - }), - }); + await register(baseUrl, { podName: pod, email: `${pod}@example.ai`, password: 't' }); } } diff --git a/test/integration/RestrictedIdentity.test.ts b/test/integration/RestrictedIdentity.test.ts index 6295d937f..684af0185 100644 --- a/test/integration/RestrictedIdentity.test.ts +++ b/test/integration/RestrictedIdentity.test.ts @@ -1,9 +1,10 @@ import { fetch } from 'cross-fetch'; import type { App } from '../../src/init/App'; import { joinUrl } from '../../src/util/PathUtil'; +import { register } from '../util/AccountUtil'; +import type { User } from '../util/AccountUtil'; import { getPort } from '../util/Util'; import { getDefaultVariables, getTestConfigPath, instantiateFromConfig } from './Config'; -import { IdentityTestState } from './IdentityTestState'; const port = getPort('RestrictedIdentity'); const baseUrl = `http://localhost:${port}/`; @@ -16,16 +17,13 @@ jest.spyOn(process, 'emitWarning').mockImplementation(); describe('A server with restricted IDP access', (): void => { let app: App; - const settings = { + const user: User = { podName: 'alice', email: 'alice@test.email', password: 'password', - confirmPassword: 'password', - createWebId: true, - register: true, - createPod: true, }; const webId = joinUrl(baseUrl, 'alice/profile/card#me'); + let controls: any; beforeAll(async(): Promise => { const instances = await instantiateFromConfig( @@ -45,22 +43,17 @@ describe('A server with restricted IDP access', (): void => { let res = await fetch(joinUrl(baseUrl, '.well-known/.acl')); expect(res.status).toBe(200); - res = await fetch(joinUrl(baseUrl, 'idp/.acl')); + res = await fetch(joinUrl(baseUrl, '.account/.acl')); expect(res.status).toBe(200); }); it('can create a pod.', async(): Promise => { - const res = await fetch(`${baseUrl}idp/register/`, { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json' }, - body: JSON.stringify(settings), - }); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.webId).toBe(webId); + const result = await register(baseUrl, user); + ({ controls } = result); + expect(result.webId).toBe(webId); }); - it('can restrict registration access.', async(): Promise => { + it('can restrict account creation.', async(): Promise => { // Only allow new WebID to register const restrictedAcl = `@prefix acl: . @prefix foaf: . @@ -71,49 +64,33 @@ describe('A server with restricted IDP access', (): void => { acl:mode acl:Read, acl:Write, acl:Control; acl:accessTo <./>.`; - let res = await fetch(`${baseUrl}idp/register/.acl`, { + let res = await fetch(`${controls.account.create}.acl`, { method: 'PUT', headers: { 'content-type': 'text/turtle' }, body: restrictedAcl, }); expect(res.status).toBe(201); - expect(res.headers.get('location')).toBe(`${baseUrl}idp/register/.acl`); + expect(res.headers.get('location')).toBe(`${controls.account.create}.acl`); // Registration is now disabled - res = await fetch(`${baseUrl}idp/register/`); + res = await fetch(controls.account.create); expect(res.status).toBe(401); - res = await fetch(`${baseUrl}idp/register/`, { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json' }, - body: JSON.stringify({ ...settings, email: 'bob@test.email', podName: 'bob' }), - }); + res = await fetch(controls.account.create, { method: 'POST' }); expect(res.status).toBe(401); }); - it('can still access registration with the correct credentials.', async(): Promise => { - // Logging into session - const state = new IdentityTestState(baseUrl, 'http://mockedredirect/', baseUrl); - let url = await state.startSession(); - let res = await state.fetchIdp(url); - expect(res.status).toBe(200); - url = await state.login(url, settings.email, settings.password); - await state.consent(url); - expect(state.session.info?.webId).toBe(webId); - - // Registration still works for this WebID - res = await state.session.fetch(`${baseUrl}idp/register/`); - expect(res.status).toBe(200); - - res = await state.session.fetch(`${baseUrl}idp/register/`, { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json' }, - body: JSON.stringify({ ...settings, email: 'bob@test.email', podName: 'bob' }), + it('can still create accounts with the correct credentials.', async(): Promise => { + // Account creation still works for the WebID + let res = await fetch(controls.account.create, { + headers: { authorization: `WebID ${webId}` }, }); expect(res.status).toBe(200); - const body = await res.json(); - expect(body.webId).toBe(joinUrl(baseUrl, 'bob/profile/card#me')); - await state.session.logout(); + res = await fetch(controls.account.create, { + method: 'POST', + headers: { authorization: `WebID ${webId}` }, + }); + expect(res.status).toBe(200); }); }); diff --git a/test/integration/SeedingPods.test.ts b/test/integration/SeedingPods.test.ts index 9a49e411e..181dc82bc 100644 --- a/test/integration/SeedingPods.test.ts +++ b/test/integration/SeedingPods.test.ts @@ -1,5 +1,5 @@ import fetch from 'cross-fetch'; -import { outputJson } from 'fs-extra'; +import { ensureFile, writeJson } from 'fs-extra'; import type { App } from '../../src/init/App'; import { joinFilePath, joinUrl } from '../../src/util/PathUtil'; import { getPort } from '../util/Util'; @@ -11,34 +11,50 @@ const baseUrl = `http://localhost:${port}/`; const rootFilePath = getTestFolder('seeding-pods'); describe('A server with seeded pods', (): void => { - const seedingJson = joinFilePath(rootFilePath, 'pods.json'); + const indexUrl = joinUrl(baseUrl, '.account/'); let app: App; beforeAll(async(): Promise => { - // Create seeding config - await outputJson(seedingJson, [ + // Create the seed file + const seed = [ { - podName: 'alice', - email: 'alice@example.com', - password: 'alice-password', + email: 'test1@example.com', + password: 'password1', + pods: [ + { name: 'pod1' }, + { name: 'pod2' }, + ], }, { - podName: 'bob', - email: 'bob@example.com', - password: 'bob-password', - register: false, + email: 'test2@example.com', + password: 'password2', + pods: [ + { name: 'pod3' }, + // This will fail + { name: 'pod2' }, + ], }, - ]); - - const variables = { - ...getDefaultVariables(port, baseUrl), - 'urn:solid-server:default:variable:seededPodConfigJson': seedingJson, - }; + { + // This will all fail + email: 'test1@example.com', + password: 'password3', + pods: [ + { name: 'pod4' }, + ], + }, + ]; + const path = joinFilePath(rootFilePath, 'seed.json'); + await ensureFile(path); + await writeJson(path, seed); + // Start server with the seed config const instances = await instantiateFromConfig( 'urn:solid-server:test:Instances', getTestConfigPath('server-memory.json'), - variables, + { + ...getDefaultVariables(port, baseUrl), + 'urn:solid-server:default:variable:seedConfig': path, + }, ) as Record; ({ app } = instances); await app.start(); @@ -49,10 +65,33 @@ describe('A server with seeded pods', (): void => { await app.stop(); }); - it('has created the requested pods.', async(): Promise => { - let response = await fetch(joinUrl(baseUrl, 'alice/profile/card#me')); - expect(response.status).toBe(200); - response = await fetch(joinUrl(baseUrl, 'bob/profile/card#me')); - expect(response.status).toBe(200); + it('can seed accounts and pods.', async(): Promise => { + // Get the controls + const res = await fetch(indexUrl); + expect(res.status).toBe(200); + const { controls } = await res.json(); + + // Verify that the pods exists + await expect(fetch(joinUrl(baseUrl, 'pod1/'))).resolves.toEqual(expect.objectContaining({ status: 200 })); + await expect(fetch(joinUrl(baseUrl, 'pod2/'))).resolves.toEqual(expect.objectContaining({ status: 200 })); + await expect(fetch(joinUrl(baseUrl, 'pod3/'))).resolves.toEqual(expect.objectContaining({ status: 200 })); + await expect(fetch(joinUrl(baseUrl, 'pod4/'))).resolves.toEqual(expect.objectContaining({ status: 404 })); + + // Verify that we can log in with the accounts + await expect(fetch(controls.password.login, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'test1@example.com', password: 'password1' }), + })).resolves.toEqual(expect.objectContaining({ status: 200 })); + await expect(fetch(controls.password.login, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'test2@example.com', password: 'password2' }), + })).resolves.toEqual(expect.objectContaining({ status: 200 })); + await expect(fetch(controls.password.login, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'test1@example.com', password: 'password3' }), + })).resolves.toEqual(expect.objectContaining({ status: 403 })); }); }); diff --git a/test/integration/ServerFetch.test.ts b/test/integration/ServerFetch.test.ts index 97f80d219..ab10ae674 100644 --- a/test/integration/ServerFetch.test.ts +++ b/test/integration/ServerFetch.test.ts @@ -11,6 +11,8 @@ const baseUrl = `http://localhost:${port}/`; // Some tests with real Requests/Responses until the mocking library has been removed from the tests describe('A Solid server', (): void => { + const document = `${baseUrl}document`; + const container = `${baseUrl}container/`; let app: App; beforeAll(async(): Promise => { @@ -31,76 +33,70 @@ describe('A Solid server', (): void => { await app.stop(); }); + it('can PUT to containers.', async(): Promise => { + const res = await fetch(container, { + method: 'PUT', + headers: { + 'content-type': 'text/turtle', + }, + body: ' .', + }); + expect(res.status).toBe(201); + expect(res.headers.get('location')).toBe(container); + }); + + it('can PUT to documents.', async(): Promise => { + const res = await fetch(document, { + method: 'PUT', + headers: { + 'content-type': 'text/turtle', + }, + body: ' .', + }); + expect(res.status).toBe(201); + expect(res.headers.get('location')).toBe(document); + }); + it('can do a successful HEAD request to a container.', async(): Promise => { - const res = await fetch(baseUrl, { method: 'HEAD' }); + const res = await fetch(container, { method: 'HEAD' }); expect(res.status).toBe(200); }); it('can do a successful HEAD request to a container without accept headers.', async(): Promise => { - const res = await fetch(baseUrl, { method: 'HEAD', headers: { accept: '' }}); + const res = await fetch(container, { method: 'HEAD', headers: { accept: '' }}); expect(res.status).toBe(200); }); it('can do a successful HEAD request to a document.', async(): Promise => { - const url = `${baseUrl}.acl`; - const res = await fetch(url, { method: 'HEAD' }); + const res = await fetch(document, { method: 'HEAD' }); expect(res.status).toBe(200); }); it('can do a successful HEAD request to a document without accept headers.', async(): Promise => { - const url = `${baseUrl}.acl`; - const res = await fetch(url, { method: 'HEAD', headers: { accept: '' }}); + const res = await fetch(document, { method: 'HEAD', headers: { accept: '' }}); expect(res.status).toBe(200); }); it('can do a successful GET request to a container.', async(): Promise => { - const res = await fetch(baseUrl); + const res = await fetch(container); expect(res.status).toBe(200); }); it('can do a successful GET request to a container without accept headers.', async(): Promise => { - const res = await fetch(baseUrl, { headers: { accept: '' }}); + const res = await fetch(container, { headers: { accept: '' }}); expect(res.status).toBe(200); }); it('can do a successful GET request to a document.', async(): Promise => { - const url = `${baseUrl}.acl`; - const res = await fetch(url); + const res = await fetch(document); expect(res.status).toBe(200); }); it('can do a successful GET request to a document without accept headers.', async(): Promise => { - const url = `${baseUrl}.acl`; - const res = await fetch(url, { headers: { accept: '' }}); + const res = await fetch(document, { headers: { accept: '' }}); expect(res.status).toBe(200); }); - it('can PUT to containers.', async(): Promise => { - const url = `${baseUrl}containerPUT/`; - const res = await fetch(url, { - method: 'PUT', - headers: { - 'content-type': 'text/turtle', - }, - body: ' .', - }); - expect(res.status).toBe(201); - expect(res.headers.get('location')).toBe(url); - }); - - it('can PUT to resources.', async(): Promise => { - const url = `${baseUrl}resourcePUT`; - const res = await fetch(url, { - method: 'PUT', - headers: { - 'content-type': 'text/turtle', - }, - body: ' .', - }); - expect(res.status).toBe(201); - expect(res.headers.get('location')).toBe(url); - }); - it('can handle PUT errors.', async(): Promise => { // There was a specific case where the following request caused the connection to close instead of error const res = await fetch(baseUrl, { @@ -116,7 +112,7 @@ describe('A Solid server', (): void => { }); it('can POST to create a container.', async(): Promise => { - const res = await fetch(baseUrl, { + const res = await fetch(container, { method: 'POST', headers: { 'content-type': 'text/turtle', @@ -126,58 +122,41 @@ describe('A Solid server', (): void => { body: ' .', }); expect(res.status).toBe(201); - expect(res.headers.get('location')).toBe(`${baseUrl}containerPOST/`); + expect(res.headers.get('location')).toBe(`${container}containerPOST/`); }); it('can POST to create a document.', async(): Promise => { - const res = await fetch(baseUrl, { + const res = await fetch(container, { method: 'POST', headers: { 'content-type': 'text/turtle', - slug: 'resourcePOST', + slug: 'documentPOST', }, body: ' .', }); expect(res.status).toBe(201); - expect(res.headers.get('location')).toBe(`${baseUrl}resourcePOST`); + expect(res.headers.get('location')).toBe(`${container}documentPOST`); }); it('can DELETE containers.', async(): Promise => { - const url = `${baseUrl}containerDELETE/`; - await fetch(url, { - method: 'PUT', - headers: { - 'content-type': 'text/turtle', - }, - body: ' .', - }); - const res = await fetch(url, { method: 'DELETE' }); + const res = await fetch(`${container}containerPOST/`, { method: 'DELETE' }); expect(res.status).toBe(205); }); it('can DELETE documents.', async(): Promise => { - const url = `${baseUrl}resourceDELETE`; - await fetch(url, { - method: 'PUT', - headers: { - 'content-type': 'text/turtle', - }, - body: ' .', - }); - const res = await fetch(url, { method: 'DELETE' }); + const res = await fetch(`${container}documentPOST`, { method: 'DELETE' }); expect(res.status).toBe(205); }); it('can PATCH documents.', async(): Promise => { - const url = `${baseUrl}resourcePATCH`; - await fetch(url, { + await fetch(document, { method: 'PUT', headers: { 'content-type': 'text/turtle', }, body: ' .', }); - const res = await fetch(url, { + const res = await fetch(document, { method: 'PATCH', headers: { 'content-type': 'application/sparql-update', @@ -188,14 +167,13 @@ describe('A Solid server', (): void => { }); it('can not PATCH containers.', async(): Promise => { - const url = `${baseUrl}containerPATCH/`; - await fetch(url, { + await fetch(container, { method: 'PUT', headers: { 'content-type': 'text/turtle', }, }); - const res = await fetch(url, { + const res = await fetch(container, { method: 'PATCH', headers: { 'content-type': 'application/sparql-update', @@ -206,15 +184,7 @@ describe('A Solid server', (): void => { }); it('can PATCH metadata resources.', async(): Promise => { - const url = `${baseUrl}resourcePATCH`; - await fetch(url, { - method: 'PUT', - headers: { - 'content-type': 'text/turtle', - }, - body: ' .', - }); - const res = await fetch(`${url}.meta`, { + const res = await fetch(`${document}.meta`, { method: 'PATCH', headers: { 'content-type': 'application/sparql-update', diff --git a/test/integration/Subdomains.test.ts b/test/integration/Subdomains.test.ts index 9d25b273f..8597aab74 100644 --- a/test/integration/Subdomains.test.ts +++ b/test/integration/Subdomains.test.ts @@ -1,5 +1,7 @@ import fetch from 'cross-fetch'; import type { App } from '../../src/init/App'; +import { register } from '../util/AccountUtil'; +import type { User } from '../util/AccountUtil'; import { getPort } from '../util/Util'; import { getDefaultVariables, @@ -28,16 +30,16 @@ const stores: [string, any][] = [ // Simulating subdomains using the forwarded header so no DNS changes are required describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardown }): void => { let app: App; - const settings = { - podName: 'alice', - webId: 'http://test.com/#alice', - email: 'alice@test.email', + const user: User = { + email: 'alice@example.com', password: 'password', - confirmPassword: 'password', - createPod: true, + webId: 'http://example.com/#alice', + podName: 'alice', }; const podHost = `alice.localhost:${port}`; const podUrl = `http://${podHost}/`; + let authorization: string; + let controls: any; beforeAll(async(): Promise => { const variables: Record = { @@ -74,7 +76,7 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo let res = await fetch(`${baseUrl}alice`, { method: 'PUT', headers: { - authorization: `WebID ${settings.webId}`, + authorization: `WebID ${user.webId}`, 'content-type': 'text/plain', }, body: 'this is new data!', @@ -90,13 +92,9 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo describe('handling pods', (): void => { it('creates pods in a subdomain.', async(): Promise => { - const res = await fetch(`${baseUrl}idp/register/`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(settings), - }); - expect(res.status).toBe(200); - await expect(res.text()).resolves.toContain(podUrl); + const result = await register(baseUrl, user); + ({ controls, authorization } = result); + expect(result.pod).toBe(podUrl); }); it('can fetch the created pod in a subdomain.', async(): Promise => { @@ -113,7 +111,7 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo const res = await fetch(`${baseUrl}.acl`, { headers: { forwarded: `host=${podHost}`, - authorization: `WebID ${settings.webId}`, + authorization: `WebID ${user.webId}`, }, }); expect(res.status).toBe(200); @@ -123,7 +121,7 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo let res = await fetch(`${baseUrl}alice`, { headers: { forwarded: `host=${podHost}`, - authorization: `WebID ${settings.webId}`, + authorization: `WebID ${user.webId}`, }, }); expect(res.status).toBe(404); @@ -132,7 +130,7 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo method: 'PUT', headers: { forwarded: `host=${podHost}`, - authorization: `WebID ${settings.webId}`, + authorization: `WebID ${user.webId}`, 'content-type': 'text/plain', }, body: 'this is new data!', @@ -143,7 +141,7 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo res = await fetch(`${baseUrl}alice`, { headers: { forwarded: `host=${podHost}`, - authorization: `WebID ${settings.webId}`, + authorization: `WebID ${user.webId}`, }, }); expect(res.status).toBe(200); @@ -151,13 +149,12 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo }); it('should not be able to create a pod with the same name.', async(): Promise => { - const newSettings = { ...settings, webId: 'http://test.com/#bob', email: 'bob@test.email' }; - const res = await fetch(`${baseUrl}idp/register/`, { + const res = await fetch(controls.account.pod, { method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(newSettings), + headers: { authorization, 'content-type': 'application/json' }, + body: JSON.stringify({ name: user.podName }), }); - expect(res.status).toBe(409); + expect(res.status).toBe(400); await expect(res.text()).resolves.toContain(`There already is a resource at ${podUrl}`); }); }); diff --git a/test/integration/config/ldp-with-acp.json b/test/integration/config/ldp-with-acp.json index 822fc26b7..d6cb3f569 100644 --- a/test/integration/config/ldp-with-acp.json +++ b/test/integration/config/ldp-with-acp.json @@ -11,9 +11,9 @@ "css:config/identity/access/public.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/no-accounts.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/disabled.json", "css:config/ldp/authentication/debug-auth-header.json", "css:config/ldp/authorization/acp.json", "css:config/ldp/handler/default.json", diff --git a/test/integration/config/ldp-with-auth.json b/test/integration/config/ldp-with-auth.json index 4d37bb2ea..ea49cc7bb 100644 --- a/test/integration/config/ldp-with-auth.json +++ b/test/integration/config/ldp-with-auth.json @@ -11,9 +11,9 @@ "css:config/identity/access/public.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/no-accounts.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/disabled.json", "css:config/ldp/authentication/debug-auth-header.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/test/integration/config/legacy-websockets.json b/test/integration/config/legacy-websockets.json index 578fa52ac..be555f20e 100644 --- a/test/integration/config/legacy-websockets.json +++ b/test/integration/config/legacy-websockets.json @@ -11,9 +11,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/test/integration/config/permission-table.json b/test/integration/config/permission-table.json index a206c6d78..3ead1c696 100644 --- a/test/integration/config/permission-table.json +++ b/test/integration/config/permission-table.json @@ -11,9 +11,9 @@ "css:config/identity/access/public.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/no-accounts.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/disabled.json", "css:config/ldp/authentication/debug-auth-header.json", "css:config/ldp/handler/default.json", diff --git a/test/integration/config/quota-global.json b/test/integration/config/quota-global.json index 644df6528..540f943a4 100644 --- a/test/integration/config/quota-global.json +++ b/test/integration/config/quota-global.json @@ -11,9 +11,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/allow-all.json", "css:config/ldp/handler/default.json", diff --git a/test/integration/config/quota-pod.json b/test/integration/config/quota-pod.json index 735a15a17..4bed789c7 100644 --- a/test/integration/config/quota-pod.json +++ b/test/integration/config/quota-pod.json @@ -11,9 +11,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/allow-all.json", "css:config/ldp/handler/default.json", diff --git a/test/integration/config/restricted-idp.json b/test/integration/config/restricted-idp.json index ad72900c6..843418055 100644 --- a/test/integration/config/restricted-idp.json +++ b/test/integration/config/restricted-idp.json @@ -11,10 +11,10 @@ "css:config/identity/access/restricted.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", - "css:config/ldp/authentication/dpop-bearer.json", + "css:config/ldp/authentication/debug-auth-header.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", "css:config/ldp/metadata-parser/default.json", diff --git a/test/integration/config/server-dynamic-unsafe.json b/test/integration/config/server-dynamic-unsafe.json index c9c3b6fb4..a14dec36a 100644 --- a/test/integration/config/server-dynamic-unsafe.json +++ b/test/integration/config/server-dynamic-unsafe.json @@ -11,9 +11,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/unsafe-no-check.json", "css:config/identity/pod/dynamic.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/debug-auth-header.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", @@ -29,7 +29,7 @@ "css:config/util/index/default.json", "css:config/util/logging/winston.json", "css:config/util/representation-conversion/default.json", - "css:config/util/resource-locker/memory.json", + "css:config/util/resource-locker/debug-void.json", "css:config/util/variables/default.json" ], "@graph": [ diff --git a/test/integration/config/server-file.json b/test/integration/config/server-file.json index 414411e07..1d39cdd0b 100644 --- a/test/integration/config/server-file.json +++ b/test/integration/config/server-file.json @@ -11,9 +11,9 @@ "css:config/identity/access/public.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/test/integration/config/server-memory.json b/test/integration/config/server-memory.json index 2d7c5621b..aae4f109a 100644 --- a/test/integration/config/server-memory.json +++ b/test/integration/config/server-memory.json @@ -11,9 +11,9 @@ "css:config/identity/access/public.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/test/integration/config/server-middleware.json b/test/integration/config/server-middleware.json index a2da049d3..c1b40eeff 100644 --- a/test/integration/config/server-middleware.json +++ b/test/integration/config/server-middleware.json @@ -11,9 +11,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/test/integration/config/server-redis-lock.json b/test/integration/config/server-redis-lock.json index f8d9bf388..8adf98b41 100644 --- a/test/integration/config/server-redis-lock.json +++ b/test/integration/config/server-redis-lock.json @@ -8,12 +8,12 @@ "css:config/http/notifications/disabled.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", - - + "css:config/identity/access/public.json", + "css:config/identity/email/default.json", + "css:config/identity/handler/default.json", + "css:config/identity/interaction/no-accounts.json", + "css:config/identity/ownership/unsafe-no-check.json", "css:config/identity/pod/static.json", - - - "css:config/identity/registration/disabled.json", "css:config/ldp/authentication/debug-auth-header.json", "css:config/ldp/authorization/allow-all.json", "css:config/ldp/handler/default.json", @@ -30,10 +30,7 @@ "css:config/util/logging/winston.json", "css:config/util/representation-conversion/default.json", "css:config/util/resource-locker/redis.json", - "css:config/util/variables/default.json", - - "css:config/identity/handler/account-store/default.json", - "css:config/identity/ownership/unsafe-no-check.json" + "css:config/util/variables/default.json" ], "@graph": [ { diff --git a/test/integration/config/server-subdomains-unsafe.json b/test/integration/config/server-subdomains-unsafe.json index 03fe9a7a5..983e8666c 100644 --- a/test/integration/config/server-subdomains-unsafe.json +++ b/test/integration/config/server-subdomains-unsafe.json @@ -11,9 +11,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/default.json", "css:config/identity/ownership/unsafe-no-check.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/enabled.json", "css:config/ldp/authentication/debug-auth-header.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/test/integration/config/server-without-auth.json b/test/integration/config/server-without-auth.json index 312041a09..da86fcb84 100644 --- a/test/integration/config/server-without-auth.json +++ b/test/integration/config/server-without-auth.json @@ -8,12 +8,12 @@ "css:config/http/notifications/websockets.json", "css:config/http/server-factory/http.json", "css:config/http/static/default.json", - - - + "css:config/identity/access/public.json", + "css:config/identity/email/default.json", + "css:config/identity/handler/default.json", + "css:config/identity/interaction/no-accounts.json", "css:config/identity/ownership/unsafe-no-check.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/disabled.json", "css:config/ldp/authentication/dpop-bearer.json", "css:config/ldp/authorization/allow-all.json", "css:config/ldp/handler/default.json", @@ -30,9 +30,7 @@ "css:config/util/logging/winston.json", "css:config/util/representation-conversion/default.json", "css:config/util/resource-locker/memory.json", - "css:config/util/variables/default.json", - - "css:config/identity/handler/account-store/default.json" + "css:config/util/variables/default.json" ], "@graph": [ ] diff --git a/test/integration/config/webhook-notifications.json b/test/integration/config/webhook-notifications.json index 66ffc12cb..b29b2b5d8 100644 --- a/test/integration/config/webhook-notifications.json +++ b/test/integration/config/webhook-notifications.json @@ -11,9 +11,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/no-accounts.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/disabled.json", "css:config/ldp/authentication/debug-auth-header.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/test/integration/config/websocket-notifications.json b/test/integration/config/websocket-notifications.json index 946122823..794eb56ed 100644 --- a/test/integration/config/websocket-notifications.json +++ b/test/integration/config/websocket-notifications.json @@ -11,9 +11,9 @@ "css:config/identity/access/public.json", "css:config/identity/email/default.json", "css:config/identity/handler/default.json", + "css:config/identity/interaction/no-accounts.json", "css:config/identity/ownership/token.json", "css:config/identity/pod/static.json", - "css:config/identity/registration/disabled.json", "css:config/ldp/authentication/debug-auth-header.json", "css:config/ldp/authorization/webacl.json", "css:config/ldp/handler/default.json", diff --git a/test/unit/authorization/OwnerPermissionReader.test.ts b/test/unit/authorization/OwnerPermissionReader.test.ts index b7c79d2d7..c35b6202b 100644 --- a/test/unit/authorization/OwnerPermissionReader.test.ts +++ b/test/unit/authorization/OwnerPermissionReader.test.ts @@ -4,12 +4,12 @@ import { AclMode } from '../../../src/authorization/permissions/AclPermissionSet import type { AccessMap } from '../../../src/authorization/permissions/Permissions'; import type { AuxiliaryIdentifierStrategy } from '../../../src/http/auxiliary/AuxiliaryIdentifierStrategy'; import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; -import type { - AccountSettings, - AccountStore, -} from '../../../src/identity/interaction/email-password/storage/AccountStore'; +import type { Account } from '../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../src/identity/interaction/account/util/AccountStore'; +import type { WebIdStore } from '../../../src/identity/interaction/webid/util/WebIdStore'; import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap'; +import { createAccount } from '../../util/AccountUtil'; import { compareMaps } from '../../util/Util'; describe('An OwnerPermissionReader', (): void => { @@ -18,8 +18,9 @@ describe('An OwnerPermissionReader', (): void => { let credentials: Credentials; let identifier: ResourceIdentifier; let requestedModes: AccessMap; - let settings: AccountSettings; + let account: Account; let accountStore: jest.Mocked; + let webIdStore: jest.Mocked; let aclStrategy: jest.Mocked; const identifierStrategy = new SingleRootIdentifierStrategy('http://example.com/'); let reader: OwnerPermissionReader; @@ -31,26 +32,23 @@ describe('An OwnerPermissionReader', (): void => { requestedModes = new IdentifierSetMultiMap([[ identifier, AclMode.control ]]) as any; - settings = { - useIdp: true, - podBaseUrl, - clientCredentials: [], - }; + account = createAccount(); + account.pods[podBaseUrl] = 'url'; + account.webIds[owner] = 'url'; + + webIdStore = { + get: jest.fn().mockResolvedValue([ account.id ]), + } as any; accountStore = { - getSettings: jest.fn(async(webId: string): Promise => { - if (webId === owner) { - return settings; - } - throw new Error('No account'); - }), + get: jest.fn().mockResolvedValue(account), } as any; aclStrategy = { isAuxiliaryIdentifier: jest.fn((id): boolean => id.path.endsWith('.acl')), } as any; - reader = new OwnerPermissionReader(accountStore, aclStrategy, identifierStrategy); + reader = new OwnerPermissionReader(webIdStore, accountStore, aclStrategy, identifierStrategy); }); it('returns empty permissions for non-ACL resources.', async(): Promise => { @@ -64,12 +62,17 @@ describe('An OwnerPermissionReader', (): void => { }); it('returns empty permissions if the agent has no account.', async(): Promise => { - credentials.agent!.webId = 'http://example.com/someone/else'; + webIdStore.get.mockResolvedValueOnce([]); + compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap()); + }); + + it('returns empty permissions if no account was found for the stored ID.', async(): Promise => { + accountStore.get.mockResolvedValueOnce(undefined); compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap()); }); it('returns empty permissions if the account has no pod.', async(): Promise => { - delete settings.podBaseUrl; + delete account.pods[podBaseUrl]; compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap()); }); diff --git a/test/unit/http/input/metadata/AuthorizationParser.test.ts b/test/unit/http/input/metadata/AuthorizationParser.test.ts new file mode 100644 index 000000000..2bb3c0af7 --- /dev/null +++ b/test/unit/http/input/metadata/AuthorizationParser.test.ts @@ -0,0 +1,34 @@ +import { DataFactory } from 'n3'; +import { AuthorizationParser } from '../../../../../src/http/input/metadata/AuthorizationParser'; +import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; +import type { HttpRequest } from '../../../../../src/server/HttpRequest'; +import namedNode = DataFactory.namedNode; + +describe('An AuthorizationParser', (): void => { + const parser = new AuthorizationParser({ custom: 'http://example.com/pred' }); + let request: HttpRequest; + let metadata: RepresentationMetadata; + + beforeEach(async(): Promise => { + request = { headers: {}} as HttpRequest; + metadata = new RepresentationMetadata(); + }); + + it('does nothing if there is no authorization header.', async(): Promise => { + await parser.handle({ request, metadata }); + expect(metadata.quads()).toHaveLength(0); + }); + + it('converts the authorization header to the relevant triple.', async(): Promise => { + request.headers.authorization = 'custom my-value'; + await parser.handle({ request, metadata }); + expect(metadata.quads()).toHaveLength(1); + expect(metadata.get(namedNode('http://example.com/pred'))?.value).toBe('my-value'); + }); + + it('ignores unknown values.', async(): Promise => { + request.headers.authorization = 'unknown my-value'; + await parser.handle({ request, metadata }); + expect(metadata.quads()).toHaveLength(0); + }); +}); diff --git a/test/unit/http/input/metadata/CookieParser.test.ts b/test/unit/http/input/metadata/CookieParser.test.ts new file mode 100644 index 000000000..043051ea0 --- /dev/null +++ b/test/unit/http/input/metadata/CookieParser.test.ts @@ -0,0 +1,29 @@ +import { DataFactory } from 'n3'; +import { CookieParser } from '../../../../../src/http/input/metadata/CookieParser'; +import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; +import type { HttpRequest } from '../../../../../src/server/HttpRequest'; +import namedNode = DataFactory.namedNode; + +describe('A CookieParser', (): void => { + const parser = new CookieParser({ custom1: 'http://example.com/pred1', custom2: 'http://example.com/pred2' }); + let request: HttpRequest; + let metadata: RepresentationMetadata; + + beforeEach(async(): Promise => { + request = { headers: {}} as HttpRequest; + metadata = new RepresentationMetadata(); + }); + + it('does nothing if there is no cookie header.', async(): Promise => { + await parser.handle({ request, metadata }); + expect(metadata.quads()).toHaveLength(0); + }); + + it('converts the authorization header to the relevant triple.', async(): Promise => { + request.headers.cookie = 'custom1=my-value;unknown=unknown-value;custom2=other-value'; + await parser.handle({ request, metadata }); + expect(metadata.quads()).toHaveLength(2); + expect(metadata.get(namedNode('http://example.com/pred1'))?.value).toBe('my-value'); + expect(metadata.get(namedNode('http://example.com/pred2'))?.value).toBe('other-value'); + }); +}); diff --git a/test/unit/http/output/metadata/CookieMetadataWriter.test.ts b/test/unit/http/output/metadata/CookieMetadataWriter.test.ts new file mode 100644 index 000000000..18cc256fe --- /dev/null +++ b/test/unit/http/output/metadata/CookieMetadataWriter.test.ts @@ -0,0 +1,39 @@ +import { DataFactory } from 'n3'; +import { createResponse } from 'node-mocks-http'; +import { CookieMetadataWriter } from '../../../../../src/http/output/metadata/CookieMetadataWriter'; +import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; +import type { HttpResponse } from '../../../../../src/server/HttpResponse'; +import namedNode = DataFactory.namedNode; +import literal = DataFactory.literal; + +describe('A CookieMetadataWriter', (): void => { + const writer = new CookieMetadataWriter({ + 'http://example.com/pred1': { name: 'custom1' }, + 'http://example.com/pred2': { name: 'custom2', expirationUri: 'http://example.com/pred2expiration' }, + }); + let metadata: RepresentationMetadata; + let response: HttpResponse; + + beforeEach(async(): Promise => { + metadata = new RepresentationMetadata(); + response = createResponse() as HttpResponse; + }); + + it('adds no headers if there is no relevant metadata.', async(): Promise => { + await expect(writer.handle({ response, metadata })).resolves.toBeUndefined(); + expect(response.getHeaders()).toEqual({}); + }); + + it('adds the relevant set-cookie headers.', async(): Promise => { + const date = new Date('2015-10-21T07:28:00.000Z'); + metadata.add(namedNode('http://example.com/pred1'), literal('my-value')); + metadata.add(namedNode('http://example.com/pred2'), literal('other-value')); + metadata.add(namedNode('http://example.com/pred2expiration'), literal(date.toISOString())); + metadata.add(namedNode('http://example.com/unknown'), literal('unknown-value')); + await expect(writer.handle({ response, metadata })).resolves.toBeUndefined(); + expect(response.getHeader('set-cookie')).toEqual([ + 'custom1=my-value; Path=/; SameSite=Lax', + 'custom2=other-value; Path=/; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax', + ]); + }); +}); diff --git a/test/unit/identity/ControlHandler.test.ts b/test/unit/identity/ControlHandler.test.ts deleted file mode 100644 index c2742b3a1..000000000 --- a/test/unit/identity/ControlHandler.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation'; -import { ControlHandler } from '../../../src/identity/interaction/ControlHandler'; -import type { InteractionHandler, InteractionHandlerInput } from '../../../src/identity/interaction/InteractionHandler'; -import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute'; -import { APPLICATION_JSON } from '../../../src/util/ContentTypes'; -import { InternalServerError } from '../../../src/util/errors/InternalServerError'; -import { readJsonStream } from '../../../src/util/StreamUtil'; - -describe('A ControlHandler', (): void => { - const input: InteractionHandlerInput = {} as any; - let controls: Record>; - let source: jest.Mocked; - let handler: ControlHandler; - - beforeEach(async(): Promise => { - controls = { - login: { getPath: jest.fn().mockReturnValue('http://example.com/login/') } as any, - register: { getPath: jest.fn().mockReturnValue('http://example.com/register/') } as any, - }; - - source = { - canHandle: jest.fn(), - handle: jest.fn().mockResolvedValue(new BasicRepresentation(JSON.stringify({ data: 'data' }), APPLICATION_JSON)), - } as any; - - handler = new ControlHandler(source, controls); - }); - - it('can handle any input its source can handle.', async(): Promise => { - await expect(handler.canHandle(input)).resolves.toBeUndefined(); - - source.canHandle.mockRejectedValueOnce(new Error('bad data')); - await expect(handler.canHandle(input)).rejects.toThrow('bad data'); - }); - - it('errors in case its source does not return JSON.', async(): Promise => { - source.handle.mockResolvedValueOnce(new BasicRepresentation()); - await expect(handler.handle(input)).rejects.toThrow(InternalServerError); - }); - - it('adds controls to the source response.', async(): Promise => { - const result = await handler.handle(input); - await expect(readJsonStream(result.data)).resolves.toEqual({ - data: 'data', - apiVersion: '0.4', - controls: { - login: 'http://example.com/login/', - register: 'http://example.com/register/', - }, - }); - }); -}); diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts index 11171b9f6..ed1dddc2e 100644 --- a/test/unit/identity/IdentityProviderHttpHandler.test.ts +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -1,31 +1,27 @@ import type { Operation } from '../../../src/http/Operation'; import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../src/http/representation/Representation'; -import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata'; import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory'; import type { IdentityProviderHttpHandlerArgs } from '../../../src/identity/IdentityProviderHttpHandler'; import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler'; +import type { CookieStore } from '../../../src/identity/interaction/account/util/CookieStore'; import type { Interaction, InteractionHandler } from '../../../src/identity/interaction/InteractionHandler'; import type { HttpRequest } from '../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../src/server/HttpResponse'; -import { getBestPreference } from '../../../src/storage/conversion/ConversionUtil'; -import type { - RepresentationConverter, - RepresentationConverterArgs, -} from '../../../src/storage/conversion/RepresentationConverter'; -import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../../src/util/ContentTypes'; -import { CONTENT_TYPE } from '../../../src/util/Vocabularies'; +import { SOLID_HTTP } from '../../../src/util/Vocabularies'; import type Provider from '../../../templates/types/oidc-provider'; describe('An IdentityProviderHttpHandler', (): void => { + const cookie = 'cookie'; + const accountId = 'accountId'; const request: HttpRequest = {} as any; const response: HttpResponse = {} as any; const oidcInteraction: Interaction = {} as any; let operation: Operation; let representation: Representation; let providerFactory: jest.Mocked; - let converter: jest.Mocked; let provider: jest.Mocked; + let cookieStore: jest.Mocked; let handler: jest.Mocked; let idpHandler: IdentityProviderHttpHandler; @@ -36,6 +32,7 @@ describe('An IdentityProviderHttpHandler', (): void => { preferences: { type: { 'text/html': 1 }}, body: new BasicRepresentation(), }; + operation.body.metadata.set(SOLID_HTTP.terms.accountCookie, cookie); provider = { interactionDetails: jest.fn().mockReturnValue(oidcInteraction), @@ -45,14 +42,12 @@ describe('An IdentityProviderHttpHandler', (): void => { getProvider: jest.fn().mockResolvedValue(provider), }; - converter = { - handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => { - // Just find the best match; - const type = getBestPreference(input.preferences.type!, { '*/*': 1 })!; - const metadata = new RepresentationMetadata(input.representation.metadata, { [CONTENT_TYPE]: type.value }); - return new BasicRepresentation(input.representation.data, metadata); - }), - } as any; + cookieStore = { + generate: jest.fn(), + get: jest.fn().mockResolvedValue(accountId), + delete: jest.fn(), + refresh: jest.fn(), + }; representation = new BasicRepresentation(); handler = { @@ -61,7 +56,7 @@ describe('An IdentityProviderHttpHandler', (): void => { const args: IdentityProviderHttpHandlerArgs = { providerFactory, - converter, + cookieStore, handler, }; idpHandler = new IdentityProviderHttpHandler(args); @@ -72,8 +67,12 @@ describe('An IdentityProviderHttpHandler', (): void => { expect(result.statusCode).toBe(200); expect(result.data).toBe(representation.data); expect(result.metadata).toBe(representation.metadata); + expect(provider.interactionDetails).toHaveBeenCalledTimes(1); + expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response); + expect(cookieStore.get).toHaveBeenCalledTimes(1); + expect(cookieStore.get).toHaveBeenLastCalledWith(cookie); expect(handler.handleSafe).toHaveBeenCalledTimes(1); - expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); + expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction, accountId }); }); it('passes no interaction if the provider call failed.', async(): Promise => { @@ -82,22 +81,24 @@ describe('An IdentityProviderHttpHandler', (): void => { expect(result.statusCode).toBe(200); expect(result.data).toBe(representation.data); expect(result.metadata).toBe(representation.metadata); + expect(provider.interactionDetails).toHaveBeenCalledTimes(1); + expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response); + expect(cookieStore.get).toHaveBeenCalledTimes(1); + expect(cookieStore.get).toHaveBeenLastCalledWith(cookie); expect(handler.handleSafe).toHaveBeenCalledTimes(1); - expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation }); + expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation, accountId }); }); - it('converts input bodies to JSON.', async(): Promise => { - operation.body.metadata.contentType = APPLICATION_X_WWW_FORM_URLENCODED; + it('passes no accountID if there is no cookie.', async(): Promise => { + operation.body.metadata.removeAll(SOLID_HTTP.terms.accountCookie); const result = await idpHandler.handle({ operation, request, response }); expect(result.statusCode).toBe(200); expect(result.data).toBe(representation.data); expect(result.metadata).toBe(representation.metadata); + expect(provider.interactionDetails).toHaveBeenCalledTimes(1); + expect(provider.interactionDetails).toHaveBeenLastCalledWith(request, response); + expect(cookieStore.get).toHaveBeenCalledTimes(0); expect(handler.handleSafe).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { body, ...partialOperation } = operation; - expect(handler.handleSafe).toHaveBeenLastCalledWith( - { operation: expect.objectContaining(partialOperation), oidcInteraction }, - ); - expect(handler.handleSafe.mock.calls[0][0].operation.body.metadata.contentType).toBe(APPLICATION_JSON); + expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); }); }); diff --git a/test/unit/identity/IdentityUtil.test.ts b/test/unit/identity/IdentityUtil.test.ts new file mode 100644 index 000000000..bf7279b2d --- /dev/null +++ b/test/unit/identity/IdentityUtil.test.ts @@ -0,0 +1,24 @@ +import { importOidcProvider } from '../../../src/identity/IdentityUtil'; + +describe('IdentityUtil', (): void => { + it('avoids dynamic imports when testing with Jest.', async(): Promise => { + const oidc = await importOidcProvider(); + expect(oidc.default).toBeDefined(); + expect(oidc.interactionPolicy).toBeDefined(); + }); + + it('imports the oidc-provider package when not running jest.', async(): Promise => { + // We need to fool the IDP factory into thinking we are not in a test run + const jestWorkerId = process.env.JEST_WORKER_ID; + const nodeEnv = process.env.NODE_ENV; + delete process.env.JEST_WORKER_ID; + delete process.env.NODE_ENV; + + const oidc = await importOidcProvider(); + expect(oidc.default).toBeDefined(); + expect(oidc.interactionPolicy).toBeDefined(); + + process.env.JEST_WORKER_ID = jestWorkerId; + process.env.NODE_ENV = nodeEnv; + }); +}); diff --git a/test/unit/identity/configuration/AccountPromptFactory.test.ts b/test/unit/identity/configuration/AccountPromptFactory.test.ts new file mode 100644 index 000000000..142852466 --- /dev/null +++ b/test/unit/identity/configuration/AccountPromptFactory.test.ts @@ -0,0 +1,130 @@ +import { interactionPolicy } from 'oidc-provider'; +import type { KoaContextWithOIDC } from 'oidc-provider'; +import { AccountPromptFactory } from '../../../../src/identity/configuration/AccountPromptFactory'; +import type { Account } from '../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../src/identity/interaction/account/util/AccountStore'; +import type { CookieStore } from '../../../../src/identity/interaction/account/util/CookieStore'; +import { createAccount, mockAccountStore } from '../../../util/AccountUtil'; +import DefaultPolicy = interactionPolicy.DefaultPolicy; +import Prompt = interactionPolicy.Prompt; + +describe('An AccountPromptFactory', (): void => { + let ctx: KoaContextWithOIDC; + let policy: jest.Mocked; + const webId = 'http://example.com/card#me'; + let account: Account; + const accountId = 'id'; + const name = 'name'; + let accountStore: jest.Mocked; + let cookieStore: jest.Mocked; + let factory: AccountPromptFactory; + + beforeEach(async(): Promise => { + policy = [] as any; + policy.add = jest.fn(); + policy.get = jest.fn().mockReturnValue(new Prompt({ name: 'login' })); + + ctx = { + cookies: { + get: jest.fn().mockResolvedValue(undefined), + }, + oidc: { + internalAccountId: webId, + session: { + accountId: webId, + }, + }, + } as any; + + account = createAccount(); + account.webIds[webId] = 'resource'; + accountStore = mockAccountStore(account); + + cookieStore = { + generate: jest.fn(), + get: jest.fn().mockResolvedValue(accountId), + delete: jest.fn(), + refresh: jest.fn(), + }; + + factory = new AccountPromptFactory(accountStore, cookieStore, name); + }); + + describe('account prompt', (): void => { + it('generates a prompt that checks for the presence of the account cookie.', async(): Promise => { + await expect(factory.handle(policy)).resolves.toBeUndefined(); + expect(policy.add).toHaveBeenCalledTimes(1); + expect(policy.add).toHaveBeenLastCalledWith(expect.any(Prompt), 0); + const prompt = policy.add.mock.calls[0][0]; + // The first check is added automatically because the prompt is requestable + expect(prompt.checks).toHaveLength(2); + const check = prompt.checks[1]; + await expect(check.check(ctx)).resolves.toBe(false); + }); + + it('returns true if there is no cookie.', async(): Promise => { + ctx.cookies.get = jest.fn(); + await expect(factory.handle(policy)).resolves.toBeUndefined(); + const prompt = policy.add.mock.calls[0][0]; + const check = prompt.checks[1]; + await expect(check.check(ctx)).resolves.toBe(true); + }); + + it('returns true if there is no matching account.', async(): Promise => { + cookieStore.get.mockResolvedValueOnce(undefined); + ctx.cookies.get = jest.fn(); + await expect(factory.handle(policy)).resolves.toBeUndefined(); + const prompt = policy.add.mock.calls[0][0]; + const check = prompt.checks[1]; + await expect(check.check(ctx)).resolves.toBe(true); + }); + }); + + describe('WebID verification check', (): void => { + it('generates a check that checks if the active account owns the chosen WebID.', async(): Promise => { + await expect(factory.handle(policy)).resolves.toBeUndefined(); + expect(policy.get).toHaveBeenCalledTimes(1); + expect(policy.get).toHaveBeenLastCalledWith('login'); + const prompt = policy.get.mock.results[0].value; + const check = prompt.checks[0]; + await expect(check.check(ctx)).resolves.toBe(false); + }); + + it('triggers if the account does not own the WebID.', async(): Promise => { + delete account.webIds[webId]; + await expect(factory.handle(policy)).resolves.toBeUndefined(); + const prompt = policy.get.mock.results[0].value; + const check = prompt.checks[0]; + await expect(check.check(ctx)).resolves.toBe(true); + }); + + it('does not trigger if there is no session with an accountId.', async(): Promise => { + delete (ctx.oidc as any).session; + await expect(factory.handle(policy)).resolves.toBeUndefined(); + const prompt = policy.get.mock.results[0].value; + const check = prompt.checks[0]; + await expect(check.check(ctx)).resolves.toBe(false); + }); + + it('does not trigger if there is no internal account ID in the context.', async(): Promise => { + delete (ctx.oidc as any).internalAccountId; + await expect(factory.handle(policy)).resolves.toBeUndefined(); + const prompt = policy.get.mock.results[0].value; + const check = prompt.checks[0]; + await expect(check.check(ctx)).resolves.toBe(false); + }); + + it('does not trigger if no account was found.', async(): Promise => { + accountStore.get.mockResolvedValue(undefined); + await expect(factory.handle(policy)).resolves.toBeUndefined(); + const prompt = policy.get.mock.results[0].value; + const check = prompt.checks[0]; + await expect(check.check(ctx)).resolves.toBe(false); + }); + + it('errors if the login prompt could not be found.', async(): Promise => { + policy.get.mockReturnValue(undefined); + await expect(factory.handle(policy)).rejects.toThrow('Missing default login policy'); + }); + }); +}); diff --git a/test/unit/identity/configuration/IdentityProviderFactory.test.ts b/test/unit/identity/configuration/IdentityProviderFactory.test.ts index 3dfe7b02e..45aba1310 100644 --- a/test/unit/identity/configuration/IdentityProviderFactory.test.ts +++ b/test/unit/identity/configuration/IdentityProviderFactory.test.ts @@ -3,23 +3,27 @@ import { exportJWK, generateKeyPair } from 'jose'; import type * as Koa from 'koa'; import type { ErrorHandler } from '../../../../src/http/output/error/ErrorHandler'; import type { ResponseWriter } from '../../../../src/http/output/ResponseWriter'; -import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import { IdentityProviderFactory } from '../../../../src/identity/configuration/IdentityProviderFactory'; import type { JwkGenerator } from '../../../../src/identity/configuration/JwkGenerator'; +import type { PromptFactory } from '../../../../src/identity/configuration/PromptFactory'; import type { - ClientCredentials, -} from '../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory'; -import type { Interaction, InteractionHandler } from '../../../../src/identity/interaction/InteractionHandler'; + ClientCredentialsStore, +} from '../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; +import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; +import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute'; import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory'; import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; -import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; import { extractErrorTerms } from '../../../../src/util/errors/HttpErrorUtil'; import { OAuthHttpError } from '../../../../src/util/errors/OAuthHttpError'; import type { errors, Configuration, KoaContextWithOIDC } from '../../../../templates/types/oidc-provider'; /* eslint-disable @typescript-eslint/naming-convention */ -jest.mock('oidc-provider', (): any => - jest.fn().mockImplementation((issuer: string, config: Configuration): any => ({ issuer, config, use: jest.fn() }))); +jest.mock('oidc-provider', (): any => { + const fn = jest.fn((issuer: string, config: Configuration): any => ({ issuer, config, use: jest.fn() })); + // The base export is the Provider class, but we also need some of the deeper exports like interactionPolicy + (fn as any).interactionPolicy = jest.requireActual('oidc-provider').interactionPolicy; + return fn; +}); const routes = { authorization: '/foo/oidc/auth', @@ -43,21 +47,22 @@ describe('An IdentityProviderFactory', (): void => { const baseUrl = 'http://example.com/foo/'; const oidcPath = '/oidc'; const webId = 'http://alice.example.com/card#me'; - const redirectUrl = 'http://example.com/login/'; - const oidcInteraction: Interaction = {} as any; + let oidcInteraction: Interaction; + let interactionRoute: InteractionRoute; let ctx: KoaContextWithOIDC; - let interactionHandler: jest.Mocked; + let promptFactory: jest.Mocked; let adapterFactory: jest.Mocked; let storage: jest.Mocked>; let jwkGenerator: jest.Mocked; - let credentialStorage: jest.Mocked>; + let clientCredentialsStore: jest.Mocked; let errorHandler: jest.Mocked; let responseWriter: jest.Mocked; let factory: IdentityProviderFactory; beforeAll(async(): Promise => { // We need to fool the IDP factory into thinking we are not in a test run, - // otherwise we can't mock the oidc-provider library due to the workaround in the code there. + // otherwise we can't mock the oidc-provider library, + // as the `importOidcProvider` utility function always calls `jest.requireActual`. jestWorkerId = process.env.JEST_WORKER_ID; nodeEnv = process.env.NODE_ENV; delete process.env.JEST_WORKER_ID; @@ -74,6 +79,12 @@ describe('An IdentityProviderFactory', (): void => { // where we use the actual library instead of a mock. baseConfig = { claims: { webid: [ 'webid', 'client_webid' ]}, features: { devInteractions: { enabled: false }}}; + oidcInteraction = { prompt: { name: 'account' }} as any; + + interactionRoute = { + getPath: jest.fn().mockReturnValue('http://example.com/interaction/'), + } as any; + ctx = { method: 'GET', req: Readable.from('data'), @@ -88,8 +99,8 @@ describe('An IdentityProviderFactory', (): void => { accepts: jest.fn().mockReturnValue('type'), } as any; - interactionHandler = { - handleSafe: jest.fn().mockRejectedValue(new FoundHttpError(redirectUrl)), + promptFactory = { + handleSafe: jest.fn(), } as any; adapterFactory = { @@ -109,9 +120,8 @@ describe('An IdentityProviderFactory', (): void => { getPublicKey: jest.fn().mockResolvedValue({ ...await exportJWK(publicKey), alg: 'ES256' }), }; - credentialStorage = { - get: jest.fn((id: string): any => map.get(id)), - set: jest.fn((id: string, value: any): any => map.set(id, value)), + clientCredentialsStore = { + get: jest.fn(), } as any; errorHandler = { @@ -121,13 +131,14 @@ describe('An IdentityProviderFactory', (): void => { responseWriter = { handleSafe: jest.fn() } as any; factory = new IdentityProviderFactory(baseConfig, { + promptFactory, adapterFactory, baseUrl, oidcPath, - interactionHandler, + interactionRoute, storage, jwkGenerator, - credentialStorage, + clientCredentialsStore, showStackTrace: true, errorHandler, responseWriter, @@ -155,7 +166,7 @@ describe('An IdentityProviderFactory', (): void => { expect((config.pkce!.required as any)()).toBe(true); expect(config.clientDefaults?.id_token_signed_response_alg).toBe('ES256'); - await expect((config.interactions?.url as any)(ctx, oidcInteraction)).resolves.toBe(redirectUrl); + await expect((config.interactions?.url as any)(ctx, oidcInteraction)).resolves.toBe(interactionRoute.getPath()); let findResult = await config.findAccount?.({ oidc: { client: { clientId: 'clientId' }}} as any, webId); expect(findResult?.accountId).toBe(webId); @@ -166,7 +177,7 @@ describe('An IdentityProviderFactory', (): void => { await expect((config.extraTokenClaims as any)({}, {})).resolves.toEqual({}); const client = { clientId: 'my_id' }; await expect((config.extraTokenClaims as any)({}, { client })).resolves.toEqual({}); - await credentialStorage.set('my_id', { webId: 'http://example.com/foo', secret: 'my-secret' }); + clientCredentialsStore.get.mockResolvedValueOnce({ accountId: 'id', webId: 'http://example.com/foo', secret: 'my-secret' }); await expect((config.extraTokenClaims as any)({}, { client })) .resolves.toEqual({ webid: 'http://example.com/foo' }); await expect((config.extraTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' })) @@ -175,7 +186,7 @@ describe('An IdentityProviderFactory', (): void => { expect(config.features?.resourceIndicators?.enabled).toBe(true); expect((config.features?.resourceIndicators?.defaultResource as any)()).toBe('http://example.com/'); expect((config.features?.resourceIndicators?.getResourceServerInfo as any)()).toEqual({ - scope: 'webid', + scope: '', audience: 'solid', accessTokenFormat: 'jwt', jwt: { sign: { alg: 'ES256' }}, @@ -189,17 +200,9 @@ describe('An IdentityProviderFactory', (): void => { .toHaveBeenLastCalledWith({ error, request: ctx.req }); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response: ctx.res, result: { statusCode: 500 }}); - }); - it('errors if there is no valid interaction redirect.', async(): Promise => { - interactionHandler.handleSafe.mockRejectedValueOnce(new Error('bad data')); - const provider = await factory.getProvider() as any; - const { config } = provider as { config: Configuration }; - await expect((config.interactions?.url as any)(ctx, oidcInteraction)).rejects.toThrow('bad data'); - - interactionHandler.handleSafe.mockResolvedValueOnce(new BasicRepresentation()); - await expect((config.interactions?.url as any)(ctx, oidcInteraction)) - .rejects.toThrow('Could not correctly redirect for the given interaction.'); + // Test that the Prompt was added + expect(promptFactory.handleSafe).toHaveBeenCalledTimes(1); }); it('copies a field from the input config if values need to be added to it.', async(): Promise => { @@ -207,13 +210,14 @@ describe('An IdentityProviderFactory', (): void => { long: { signed: true }, }; factory = new IdentityProviderFactory(baseConfig, { + promptFactory, adapterFactory, baseUrl, oidcPath, - interactionHandler, + interactionRoute, storage, jwkGenerator, - credentialStorage, + clientCredentialsStore, showStackTrace: true, errorHandler, responseWriter, @@ -232,13 +236,14 @@ describe('An IdentityProviderFactory', (): void => { const result1 = await factory.getProvider() as unknown as { issuer: string; config: Configuration }; // Create a new factory that is not cached yet const factory2 = new IdentityProviderFactory(baseConfig, { + promptFactory, adapterFactory, baseUrl, oidcPath, - interactionHandler, + interactionRoute, storage, jwkGenerator, - credentialStorage, + clientCredentialsStore, showStackTrace: true, errorHandler, responseWriter, diff --git a/test/unit/identity/interaction/BaseInteractionHandler.test.ts b/test/unit/identity/interaction/BaseInteractionHandler.test.ts deleted file mode 100644 index 4736ee2d0..000000000 --- a/test/unit/identity/interaction/BaseInteractionHandler.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Operation } from '../../../../src/http/Operation'; -import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; -import type { Representation } from '../../../../src/http/representation/Representation'; -import { BaseInteractionHandler } from '../../../../src/identity/interaction/BaseInteractionHandler'; -import type { InteractionHandlerInput } from '../../../../src/identity/interaction/InteractionHandler'; -import { APPLICATION_JSON } from '../../../../src/util/ContentTypes'; -import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; -import { readJsonStream } from '../../../../src/util/StreamUtil'; - -class DummyBaseInteractionHandler extends BaseInteractionHandler { - public constructor() { - super({ view: 'view' }); - } - - public async handlePost(input: InteractionHandlerInput): Promise { - return new BasicRepresentation(JSON.stringify({ data: 'data' }), input.operation.target, APPLICATION_JSON); - } -} - -describe('A BaseInteractionHandler', (): void => { - const handler = new DummyBaseInteractionHandler(); - - it('can only handle GET and POST requests.', async(): Promise => { - const operation: Operation = { - method: 'DELETE', - target: { path: 'http://example.com/foo' }, - body: new BasicRepresentation(), - preferences: {}, - }; - await expect(handler.canHandle({ operation })).rejects.toThrow(MethodNotAllowedHttpError); - - operation.method = 'GET'; - await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); - - operation.method = 'POST'; - await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); - }); - - it('returns the view on GET requests.', async(): Promise => { - const operation: Operation = { - method: 'GET', - target: { path: 'http://example.com/foo' }, - body: new BasicRepresentation(), - preferences: {}, - }; - const result = await handler.handle({ operation }); - await expect(readJsonStream(result.data)).resolves.toEqual({ view: 'view' }); - }); - - it('calls the handlePost function on POST requests.', async(): Promise => { - const operation: Operation = { - method: 'POST', - target: { path: 'http://example.com/foo' }, - body: new BasicRepresentation(), - preferences: {}, - }; - const result = await handler.handle({ operation }); - await expect(readJsonStream(result.data)).resolves.toEqual({ data: 'data' }); - }); - - it('rejects other methods.', async(): Promise => { - const operation: Operation = { - method: 'DELETE', - target: { path: 'http://example.com/foo' }, - body: new BasicRepresentation(), - preferences: {}, - }; - await expect(handler.handle({ operation })).rejects.toThrow(MethodNotAllowedHttpError); - }); -}); diff --git a/test/unit/identity/interaction/ControlHandler.test.ts b/test/unit/identity/interaction/ControlHandler.test.ts new file mode 100644 index 000000000..0c0ed0380 --- /dev/null +++ b/test/unit/identity/interaction/ControlHandler.test.ts @@ -0,0 +1,134 @@ +import { ControlHandler } from '../../../../src/identity/interaction/ControlHandler'; +import type { + JsonInteractionHandler, + JsonInteractionHandlerInput, +} from '../../../../src/identity/interaction/JsonInteractionHandler'; +import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute'; + +describe('A ControlHandler', (): void => { + const input: JsonInteractionHandlerInput = {} as any; + let controls: Record; + let source: jest.Mocked; + let handler: ControlHandler; + + beforeEach(async(): Promise => { + controls = { + login: { getPath: jest.fn().mockReturnValue('http://example.com/login/') } as any, + register: { getPath: jest.fn().mockReturnValue('http://example.com/register/') } as any, + }; + + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue({ json: { data: 'data' }}), + } as any; + + handler = new ControlHandler(controls, source); + }); + + it('can handle any input its source can handle if there is one.', async(): Promise => { + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + + source.canHandle.mockRejectedValue(new Error('bad data')); + await expect(handler.canHandle(input)).rejects.toThrow('bad data'); + + handler = new ControlHandler(controls); + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + }); + + it('adds controls to the source response in the key field.', async(): Promise => { + await expect(handler.handle(input)).resolves.toEqual({ json: { + data: 'data', + login: 'http://example.com/login/', + register: 'http://example.com/register/', + }}); + }); + + it('can have handlers instead of routes as control values.', async(): Promise => { + controls.handler = { + handleSafe: jest.fn().mockResolvedValue({ json: { + key1: 'path1', + key2: 'path2', + }}), + } as any; + await expect(handler.handle(input)).resolves.toEqual({ json: { + data: 'data', + login: 'http://example.com/login/', + handler: { + key1: 'path1', + key2: 'path2', + }, + register: 'http://example.com/register/', + }}); + }); + + it('does not add route results if getting the path fails.', async(): Promise => { + controls.account = { + getPath: jest.fn((): never => { + throw new Error('missing account ID'); + }), + } as any; + await expect(handler.handle(input)).resolves.toEqual({ json: { + data: 'data', + login: 'http://example.com/login/', + register: 'http://example.com/register/', + }}); + }); + + it('does not add handler results if it returns an empty array.', async(): Promise => { + controls.array = { + handleSafe: jest.fn().mockResolvedValue({ json: []}), + } as any; + await expect(handler.handle(input)).resolves.toEqual({ json: { + data: 'data', + login: 'http://example.com/login/', + register: 'http://example.com/register/', + }}); + }); + + it('does not add handler results if it returns an empty object.', async(): Promise => { + controls.object = { + handleSafe: jest.fn().mockResolvedValue({ json: {}}), + } as any; + await expect(handler.handle(input)).resolves.toEqual({ json: { + data: 'data', + login: 'http://example.com/login/', + register: 'http://example.com/register/', + }}); + }); + + it('merges results with controls.', async(): Promise => { + source.handle.mockResolvedValueOnce({ json: { + data: 'data1', + arr: [ 'arr1' ], + arr2: [ 'arr1' ], + obj: { + key1: 'val1', + }, + obj2: { + key1: 'val1', + }, + }}); + + controls = { + data: { getPath: jest.fn().mockReturnValue('data2') } as any, + arr: { getPath: jest.fn().mockReturnValue([ 'arr2' ]) } as any, + arr2: { getPath: jest.fn().mockReturnValue({ key2: 'val2' }) } as any, + obj: { getPath: jest.fn().mockReturnValue({ key2: 'val2' }) } as any, + obj2: { getPath: jest.fn().mockReturnValue([ 'moreData2' ]) } as any, + }; + + handler = new ControlHandler(controls, source); + await expect(handler.handle(input)).resolves.toEqual({ json: { + data: 'data1', + arr: [ 'arr1', 'arr2' ], + arr2: [ 'arr1' ], + obj: { + key1: 'val1', + key2: 'val2', + }, + obj2: { + key1: 'val1', + }, + }}); + }); +}); diff --git a/test/unit/identity/interaction/CookieInteractionHandler.test.ts b/test/unit/identity/interaction/CookieInteractionHandler.test.ts new file mode 100644 index 000000000..eaabe6859 --- /dev/null +++ b/test/unit/identity/interaction/CookieInteractionHandler.test.ts @@ -0,0 +1,161 @@ +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; +import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from '../../../../src/identity/interaction/account/util/Account'; +import type { Account } from '../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../src/identity/interaction/account/util/AccountStore'; +import type { CookieStore } from '../../../../src/identity/interaction/account/util/CookieStore'; +import { CookieInteractionHandler } from '../../../../src/identity/interaction/CookieInteractionHandler'; +import type { JsonRepresentation } from '../../../../src/identity/interaction/InteractionUtil'; +import type { + JsonInteractionHandler, + JsonInteractionHandlerInput, +} from '../../../../src/identity/interaction/JsonInteractionHandler'; +import { SOLID_HTTP } from '../../../../src/util/Vocabularies'; +import { createAccount, mockAccountStore } from '../../../util/AccountUtil'; + +describe('A CookieInteractionHandler', (): void => { + const date = new Date(); + const accountId = 'accountId'; + const cookie = 'cookie'; + const target: ResourceIdentifier = { path: 'http://example.com/foo' }; + let input: JsonInteractionHandlerInput; + let output: JsonRepresentation; + let account: Account; + let source: jest.Mocked; + let accountStore: jest.Mocked; + let cookieStore: jest.Mocked; + let handler: CookieInteractionHandler; + + beforeEach(async(): Promise => { + input = { + method: 'GET', + json: {}, + metadata: new RepresentationMetadata({ [SOLID_HTTP.accountCookie]: cookie }), + target, + }; + + output = { + json: {}, + metadata: new RepresentationMetadata(), + }; + + account = createAccount(accountId); + account.settings[ACCOUNT_SETTINGS_REMEMBER_LOGIN] = true; + accountStore = mockAccountStore(account); + + cookieStore = { + get: jest.fn().mockResolvedValue(account.id), + refresh: jest.fn().mockResolvedValue(date), + } as any; + + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(output), + } as any; + + handler = new CookieInteractionHandler(source, accountStore, cookieStore); + }); + + it('can handle input its source can handle.', async(): Promise => { + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + expect(source.canHandle).toHaveBeenCalledTimes(1); + expect(source.canHandle).toHaveBeenLastCalledWith(input); + + source.canHandle.mockRejectedValueOnce(new Error('bad data')); + await expect(handler.canHandle(input)).rejects.toThrow('bad data'); + expect(source.canHandle).toHaveBeenCalledTimes(2); + expect(source.canHandle).toHaveBeenLastCalledWith(input); + }); + + it('refreshes the cookie and sets its expiration metadata if required.', async(): Promise => { + await expect(handler.handle(input)).resolves.toEqual(output); + expect(source.handle).toHaveBeenCalledTimes(1); + expect(source.handle).toHaveBeenLastCalledWith(input); + expect(cookieStore.get).toHaveBeenCalledTimes(1); + expect(cookieStore.get).toHaveBeenLastCalledWith(cookie); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(accountId); + expect(cookieStore.refresh).toHaveBeenCalledTimes(1); + expect(cookieStore.refresh).toHaveBeenLastCalledWith(cookie); + expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)?.value).toBe(cookie); + expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)?.value).toBe(date.toISOString()); + }); + + it('creates a new metadata output object if there was none.', async(): Promise => { + delete output.metadata; + await expect(handler.handle(input)).resolves.toEqual(output); + expect(source.handle).toHaveBeenCalledTimes(1); + expect(source.handle).toHaveBeenLastCalledWith(input); + expect(cookieStore.get).toHaveBeenCalledTimes(1); + expect(cookieStore.get).toHaveBeenLastCalledWith(cookie); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(accountId); + expect(cookieStore.refresh).toHaveBeenCalledTimes(1); + expect(cookieStore.refresh).toHaveBeenLastCalledWith(cookie); + // Typescript things the typing of this is `never` since we deleted it above + expect((output.metadata as any).get(SOLID_HTTP.terms.accountCookie)?.value).toBe(cookie); + expect((output.metadata as any).get(SOLID_HTTP.terms.accountCookieExpiration)?.value).toBe(date.toISOString()); + }); + + it('uses the output cookie over the input cookie if there is one.', async(): Promise => { + output.metadata!.set(SOLID_HTTP.terms.accountCookie, 'other-cookie'); + await expect(handler.handle(input)).resolves.toEqual(output); + expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)?.value).toBe('other-cookie'); + expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)?.value).toBe(date.toISOString()); + }); + + it('adds no cookie metadata if there is no cookie.', async(): Promise => { + input.metadata.removeAll(SOLID_HTTP.terms.accountCookie); + await expect(handler.handle(input)).resolves.toEqual(output); + expect(cookieStore.get).toHaveBeenCalledTimes(0); + expect(accountStore.get).toHaveBeenCalledTimes(0); + expect(cookieStore.refresh).toHaveBeenCalledTimes(0); + expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)).toBeUndefined(); + expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)).toBeUndefined(); + }); + + it('adds no cookie metadata if the output metadata already has expiration metadata.', async(): Promise => { + output.metadata!.set(SOLID_HTTP.terms.accountCookie, 'other-cookie'); + output.metadata!.set(SOLID_HTTP.terms.accountCookieExpiration, date.toISOString()); + await expect(handler.handle(input)).resolves.toEqual(output); + expect(cookieStore.get).toHaveBeenCalledTimes(0); + expect(accountStore.get).toHaveBeenCalledTimes(0); + expect(cookieStore.refresh).toHaveBeenCalledTimes(0); + }); + + it('adds no cookie metadata if no account ID was found.', async(): Promise => { + cookieStore.get.mockResolvedValue(undefined); + await expect(handler.handle(input)).resolves.toEqual(output); + expect(cookieStore.get).toHaveBeenCalledTimes(1); + expect(cookieStore.get).toHaveBeenLastCalledWith(cookie); + expect(accountStore.get).toHaveBeenCalledTimes(0); + expect(cookieStore.refresh).toHaveBeenCalledTimes(0); + expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)).toBeUndefined(); + expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)).toBeUndefined(); + }); + + it('adds no cookie metadata if the account does not want to be remembered.', async(): Promise => { + account.settings[ACCOUNT_SETTINGS_REMEMBER_LOGIN] = false; + await expect(handler.handle(input)).resolves.toEqual(output); + expect(cookieStore.get).toHaveBeenCalledTimes(1); + expect(cookieStore.get).toHaveBeenLastCalledWith(cookie); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(accountId); + expect(cookieStore.refresh).toHaveBeenCalledTimes(0); + expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)).toBeUndefined(); + expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)).toBeUndefined(); + }); + + it('adds no cookie metadata if the refresh action returns no value.', async(): Promise => { + cookieStore.refresh.mockResolvedValue(undefined); + await expect(handler.handle(input)).resolves.toEqual(output); + expect(cookieStore.get).toHaveBeenCalledTimes(1); + expect(cookieStore.get).toHaveBeenLastCalledWith(cookie); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(accountId); + expect(cookieStore.refresh).toHaveBeenCalledTimes(1); + expect(cookieStore.refresh).toHaveBeenLastCalledWith(cookie); + expect(output.metadata?.get(SOLID_HTTP.terms.accountCookie)).toBeUndefined(); + expect(output.metadata?.get(SOLID_HTTP.terms.accountCookieExpiration)).toBeUndefined(); + }); +}); diff --git a/test/unit/identity/interaction/FixedInteractionHandler.test.ts b/test/unit/identity/interaction/FixedInteractionHandler.test.ts deleted file mode 100644 index 85c9c0e2b..000000000 --- a/test/unit/identity/interaction/FixedInteractionHandler.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Operation } from '../../../../src/http/Operation'; -import { FixedInteractionHandler } from '../../../../src/identity/interaction/FixedInteractionHandler'; -import { readJsonStream } from '../../../../src/util/StreamUtil'; - -describe('A FixedInteractionHandler', (): void => { - const json = { data: 'data' }; - const operation: Operation = { target: { path: 'http://example.com/test/' }} as any; - const handler = new FixedInteractionHandler(json); - - it('returns the given JSON as response.', async(): Promise => { - const response = await handler.handle({ operation }); - await expect(readJsonStream(response.data)).resolves.toEqual(json); - expect(response.metadata.contentType).toBe('application/json'); - }); -}); diff --git a/test/unit/identity/interaction/HtmlViewHandler.test.ts b/test/unit/identity/interaction/HtmlViewHandler.test.ts index 4d1b17160..ae0fff9ee 100644 --- a/test/unit/identity/interaction/HtmlViewHandler.test.ts +++ b/test/unit/identity/interaction/HtmlViewHandler.test.ts @@ -1,6 +1,6 @@ import type { Operation } from '../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; -import { HtmlViewHandler } from '../../../../src/identity/interaction/HtmlViewHandler'; +import { HtmlViewEntry, HtmlViewHandler } from '../../../../src/identity/interaction/HtmlViewHandler'; import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute'; import { TEXT_HTML } from '../../../../src/util/ContentTypes'; import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; @@ -13,14 +13,15 @@ describe('An HtmlViewHandler', (): void => { const idpIndex = 'http://example.com/idp/'; let index: InteractionRoute; let operation: Operation; - let templates: Record>; + let templates: HtmlViewEntry[]; let templateEngine: TemplateEngine; let handler: HtmlViewHandler; beforeEach(async(): Promise => { index = { getPath: jest.fn().mockReturnValue(idpIndex), - } as any; + matchPath: jest.fn().mockReturnValue({}), + }; operation = { method: 'GET', @@ -29,10 +30,16 @@ describe('An HtmlViewHandler', (): void => { body: new BasicRepresentation(), }; - templates = { - '/templates/login.html.ejs': { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any, - '/templates/register.html.ejs': { getPath: jest.fn().mockReturnValue('http://example.com/idp/register/') } as any, - }; + templates = [ + new HtmlViewEntry({ + getPath: jest.fn().mockReturnValue('http://example.com/idp/login/'), + matchPath: jest.fn().mockReturnValue({}), + }, '/templates/login.html.ejs'), + new HtmlViewEntry({ + getPath: jest.fn().mockReturnValue('http://example.com/idp/register/'), + matchPath: jest.fn().mockReturnValue({}), + }, '/templates/register.html.ejs'), + ]; templateEngine = { handleSafe: jest.fn().mockReturnValue(Promise.resolve('')), @@ -47,7 +54,9 @@ describe('An HtmlViewHandler', (): void => { }); it('rejects unsupported paths.', async(): Promise => { - operation.target.path = 'http://example.com/idp/otherPath/'; + for (const template of templates) { + (template.route as jest.Mocked).matchPath.mockReturnValue(undefined); + } await expect(handler.canHandle({ operation })).rejects.toThrow(NotFoundHttpError); }); diff --git a/test/unit/identity/interaction/InteractionHandler.test.ts b/test/unit/identity/interaction/InteractionHandler.test.ts deleted file mode 100644 index 167fef7aa..000000000 --- a/test/unit/identity/interaction/InteractionHandler.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; -import type { Representation } from '../../../../src/http/representation/Representation'; -import { - InteractionHandler, -} from '../../../../src/identity/interaction/InteractionHandler'; -import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; - -class SimpleInteractionHandler extends InteractionHandler { - public async handle(): Promise { - return new BasicRepresentation(); - } -} - -describe('An InteractionHandler', (): void => { - const handler = new SimpleInteractionHandler(); - - it('only supports JSON data or empty bodies.', async(): Promise => { - let representation = new BasicRepresentation('{}', 'application/json'); - await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined(); - - representation = new BasicRepresentation('', 'application/x-www-form-urlencoded'); - await expect(handler.canHandle({ operation: { body: representation }} as any)) - .rejects.toThrow(NotImplementedHttpError); - - representation = new BasicRepresentation(); - await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined(); - }); -}); diff --git a/test/unit/identity/interaction/InteractionUtil.test.ts b/test/unit/identity/interaction/InteractionUtil.test.ts new file mode 100644 index 000000000..93b9b0baa --- /dev/null +++ b/test/unit/identity/interaction/InteractionUtil.test.ts @@ -0,0 +1,97 @@ +import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; +import type { AccountInteractionResults } from '../../../../src/identity/interaction/InteractionUtil'; +import { + assertOidcInteraction, finishInteraction, forgetWebId, +} from '../../../../src/identity/interaction/InteractionUtil'; +import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; +import type Provider from '../../../../templates/types/oidc-provider'; + +jest.useFakeTimers(); +jest.setSystemTime(); + +describe('InteractionUtil', (): void => { + let oidcInteraction: Interaction; + + beforeEach(async(): Promise => { + oidcInteraction = { + lastSubmission: { + login: { + accountId: 'http://example.com/card#me', + }, + }, + session: { + cookie: 'cookie', + }, + exp: (Date.now() / 1000) + 1234, + returnTo: 'returnTo', + persist: jest.fn(), + } as any; + }); + + describe('#assertOidcInteraction', (): void => { + it('does nothing if the interaction is defined.', async(): Promise => { + expect(assertOidcInteraction(oidcInteraction)).toBeUndefined(); + }); + + it('throws an error if there is no interaction.', async(): Promise => { + try { + assertOidcInteraction(); + // Make sure the function always errors + expect(true).toBe(false); + } catch (error: unknown) { + /* eslint-disable jest/no-conditional-expect */ + expect(BadRequestHttpError.isInstance(error)).toBe(true); + expect((error as BadRequestHttpError).message) + .toBe('This action can only be performed as part of an OIDC authentication flow.'); + expect((error as BadRequestHttpError).errorCode).toBe('E0002'); + /* eslint-enable jest/no-conditional-expect */ + } + }); + }); + + describe('#finishInteraction', (): void => { + const result: AccountInteractionResults = { + account: 'accountId', + }; + + it('updates the interaction.', async(): Promise => { + await expect(finishInteraction(oidcInteraction, result, false)).resolves.toBe('returnTo'); + expect(oidcInteraction.result).toBe(result); + expect(oidcInteraction.persist).toHaveBeenCalledTimes(1); + }); + + it('can merge the result into the interaction.', async(): Promise => { + await expect(finishInteraction(oidcInteraction, result, true)).resolves.toBe('returnTo'); + expect(oidcInteraction.result).toEqual({ + account: 'accountId', + login: { + accountId: 'http://example.com/card#me', + }, + }); + expect(oidcInteraction.persist).toHaveBeenCalledTimes(1); + }); + }); + + describe('#forgetWebId', (): void => { + let provider: jest.Mocked; + + beforeEach(async(): Promise => { + provider = { + // eslint-disable-next-line @typescript-eslint/naming-convention + Session: { + find: jest.fn().mockResolvedValue({ + accountId: 'accountId', + persist: jest.fn(), + }), + }, + } as any; + }); + + it('removes the accountId from the session.', async(): Promise => { + await expect(forgetWebId(provider, oidcInteraction)).resolves.toBeUndefined(); + const session = await (provider.Session.find as jest.Mock).mock.results[0].value; + expect(session.accountId).toBeUndefined(); + expect(session.persist).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/test/unit/identity/interaction/JsonConversionHandler.test.ts b/test/unit/identity/interaction/JsonConversionHandler.test.ts new file mode 100644 index 000000000..3e3ee13f8 --- /dev/null +++ b/test/unit/identity/interaction/JsonConversionHandler.test.ts @@ -0,0 +1,99 @@ +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/http/representation/Representation'; +import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; +import { JsonConversionHandler } from '../../../../src/identity/interaction/JsonConversionHandler'; +import type { JsonInteractionHandler } from '../../../../src/identity/interaction/JsonInteractionHandler'; +import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter'; +import { APPLICATION_JSON } from '../../../../src/util/ContentTypes'; +import { readJsonStream } from '../../../../src/util/StreamUtil'; + +describe('A JsonConversionHandler', (): void => { + const accountId = 'accountId'; + const oidcInteraction: Interaction = { returnTo: 'returnTo' } as any; + let convertedRepresentation: Representation; + let operation: Operation; + let source: jest.Mocked; + let converter: jest.Mocked; + let handler: JsonConversionHandler; + + beforeEach(async(): Promise => { + operation = { + method: 'GET', + target: { path: 'http://test.com/idp' }, + preferences: { type: { 'application/json': 1 }}, + body: new BasicRepresentation('{ "input": "data" }', 'application/json'), + }; + + source = { + canHandle: jest.fn(), + handle: jest.fn(), + handleSafe: jest.fn().mockResolvedValue({ json: { output: 'data' }}), + }; + + converter = { + canHandle: jest.fn(), + handle: jest.fn(async(input): Promise => { + convertedRepresentation = new BasicRepresentation(input.representation.data, 'application/json'); + return convertedRepresentation; + }), + handleSafe: jest.fn(), + }; + + handler = new JsonConversionHandler(source, converter); + }); + + it('only handle representations its converter can handle.', async(): Promise => { + await expect(handler.canHandle({ operation, accountId, oidcInteraction })).resolves.toBeUndefined(); + const error = new Error('bad data'); + converter.canHandle.mockRejectedValueOnce(error); + await expect(handler.canHandle({ operation, accountId, oidcInteraction })).rejects.toThrow(error); + }); + + it('can always handle empty bodies.', async(): Promise => { + operation.body = new BasicRepresentation(); + const error = new Error('bad data'); + converter.canHandle.mockRejectedValueOnce(error); + await expect(handler.canHandle({ operation, accountId, oidcInteraction })).resolves.toBeUndefined(); + }); + + it('calls the source with the generated JSON and converts the output back.', async(): Promise => { + const output = await handler.handle({ operation, accountId, oidcInteraction }); + expect(output).toBeDefined(); + await expect(readJsonStream(output.data)).resolves.toEqual({ output: 'data' }); + expect(output.metadata.contentType).toBe('application/json'); + expect(converter.handle).toHaveBeenCalledTimes(1); + expect(converter.handle).toHaveBeenLastCalledWith({ + identifier: operation.target, + preferences: { type: { [APPLICATION_JSON]: 1 }}, + representation: operation.body, + }); + expect(source.handleSafe).toHaveBeenCalledTimes(1); + expect(source.handleSafe).toHaveBeenLastCalledWith({ + method: operation.method, + target: operation.target, + metadata: convertedRepresentation.metadata, + json: { input: 'data' }, + oidcInteraction, + accountId, + }); + }); + + it('does not call the converter if the body is empty.', async(): Promise => { + operation.body = new BasicRepresentation(); + const output = await handler.handle({ operation, accountId, oidcInteraction }); + expect(output).toBeDefined(); + await expect(readJsonStream(output.data)).resolves.toEqual({ output: 'data' }); + expect(output.metadata.contentType).toBe('application/json'); + expect(converter.handle).toHaveBeenCalledTimes(0); + expect(source.handleSafe).toHaveBeenCalledTimes(1); + expect(source.handleSafe).toHaveBeenLastCalledWith({ + method: operation.method, + target: operation.target, + metadata: operation.body.metadata, + json: {}, + oidcInteraction, + accountId, + }); + }); +}); diff --git a/test/unit/identity/interaction/LocationInteractionHandler.test.ts b/test/unit/identity/interaction/LocationInteractionHandler.test.ts index 60125e90b..e786ea4f0 100644 --- a/test/unit/identity/interaction/LocationInteractionHandler.test.ts +++ b/test/unit/identity/interaction/LocationInteractionHandler.test.ts @@ -1,24 +1,22 @@ import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; import type { - InteractionHandler, - InteractionHandlerInput, -} from '../../../../src/identity/interaction/InteractionHandler'; + JsonInteractionHandler, + JsonInteractionHandlerInput, +} from '../../../../src/identity/interaction/JsonInteractionHandler'; import { LocationInteractionHandler } from '../../../../src/identity/interaction/LocationInteractionHandler'; import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; -import { readJsonStream } from '../../../../src/util/StreamUtil'; describe('A LocationInteractionHandler', (): void => { const representation = new BasicRepresentation(); - const input: InteractionHandlerInput = { - operation: { - target: { path: 'http://example.com/target' }, - preferences: {}, - method: 'GET', - body: new BasicRepresentation(), - }, + const input: JsonInteractionHandlerInput = { + target: { path: 'http://example.com/target' }, + method: 'GET', + json: { input: 'data' }, + metadata: new RepresentationMetadata(), }; - let source: jest.Mocked; + let source: jest.Mocked; let handler: LocationInteractionHandler; beforeEach(async(): Promise => { @@ -50,8 +48,8 @@ describe('A LocationInteractionHandler', (): void => { source.handle.mockRejectedValueOnce(new FoundHttpError(location)); const response = await handler.handle(input); - expect(response.metadata.identifier.value).toEqual(input.operation.target.path); - await expect(readJsonStream(response.data)).resolves.toEqual({ location }); + expect(response.metadata?.identifier.value).toEqual(input.target.path); + expect(response.json).toEqual({ location }); }); it('rethrows non-redirect errors.', async(): Promise => { diff --git a/test/unit/identity/interaction/LockingInteractionHandler.test.ts b/test/unit/identity/interaction/LockingInteractionHandler.test.ts new file mode 100644 index 000000000..d1c80b178 --- /dev/null +++ b/test/unit/identity/interaction/LockingInteractionHandler.test.ts @@ -0,0 +1,81 @@ +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { AccountIdRoute } from '../../../../src/identity/interaction/account/AccountIdRoute'; +import type { InteractionHandlerInput, + InteractionHandler } from '../../../../src/identity/interaction/InteractionHandler'; +import { LockingInteractionHandler } from '../../../../src/identity/interaction/LockingInteractionHandler'; +import type { ReadWriteLocker } from '../../../../src/util/locking/ReadWriteLocker'; + +describe('A LockingInteractionHandler', (): void => { + const accountId = 'accountId'; + let input: InteractionHandlerInput; + let locker: jest.Mocked; + let route: jest.Mocked; + let source: jest.Mocked; + let handler: LockingInteractionHandler; + + beforeEach(async(): Promise => { + input = { + operation: { + method: 'GET', + target: { path: 'http://example.com/foo' }, + preferences: {}, + body: new BasicRepresentation(), + }, + accountId, + }; + + locker = { + withReadLock: jest.fn(async(id, fn): Promise => fn()), + withWriteLock: jest.fn(async(id, fn): Promise => fn()), + }; + + route = { + matchPath: jest.fn(), + getPath: jest.fn().mockReturnValue('http://example.com/accountId'), + }; + + source = { + handleSafe: jest.fn(), + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue('response'), + }; + + handler = new LockingInteractionHandler(locker, route, source); + }); + + it('can handle input its source can handle.', async(): Promise => { + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + expect(source.canHandle).toHaveBeenCalledTimes(1); + expect(source.canHandle).toHaveBeenLastCalledWith(input); + + const error = new Error('bad data'); + source.canHandle.mockRejectedValueOnce(error); + await expect(handler.canHandle(input)).rejects.toThrow(error); + }); + + it('does not create a lock if there is no account ID.', async(): Promise => { + delete input.accountId; + await expect(handler.handle(input)).resolves.toBe('response'); + expect(source.handle).toHaveBeenCalledTimes(1); + expect(source.handle).toHaveBeenLastCalledWith(input); + expect(locker.withReadLock).toHaveBeenCalledTimes(0); + expect(locker.withWriteLock).toHaveBeenCalledTimes(0); + }); + + it('creates a read lock for read operations.', async(): Promise => { + await expect(handler.handle(input)).resolves.toBe('response'); + expect(source.handle).toHaveBeenCalledTimes(1); + expect(source.handle).toHaveBeenLastCalledWith(input); + expect(locker.withReadLock).toHaveBeenCalledTimes(1); + expect(locker.withWriteLock).toHaveBeenCalledTimes(0); + }); + + it('creates a write lock for write operations.', async(): Promise => { + input.operation.method = 'PUT'; + await expect(handler.handle(input)).resolves.toBe('response'); + expect(source.handle).toHaveBeenCalledTimes(1); + expect(source.handle).toHaveBeenLastCalledWith(input); + expect(locker.withReadLock).toHaveBeenCalledTimes(0); + expect(locker.withWriteLock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit/identity/interaction/OidcControlHandler.test.ts b/test/unit/identity/interaction/OidcControlHandler.test.ts new file mode 100644 index 000000000..cd1f7bd03 --- /dev/null +++ b/test/unit/identity/interaction/OidcControlHandler.test.ts @@ -0,0 +1,17 @@ +import { OidcControlHandler } from '../../../../src/identity/interaction/OidcControlHandler'; + +describe('An OidcControlHandler', (): void => { + const handler = new OidcControlHandler({ key: { + getPath: jest.fn().mockReturnValue('http://example.com/foo/'), + matchPath: jest.fn().mockReturnValue(true), + }}); + + it('returns results if there is an OIDC interaction.', async(): Promise => { + await expect(handler.handle({ oidcInteraction: {}} as any)) + .resolves.toEqual({ json: { key: 'http://example.com/foo/' }}); + }); + + it('returns an empty object if there is no OIDC interaction.', async(): Promise => { + await expect(handler.handle({ } as any)).resolves.toEqual({ json: { }}); + }); +}); diff --git a/test/unit/identity/interaction/PromptHandler.test.ts b/test/unit/identity/interaction/PromptHandler.test.ts deleted file mode 100644 index 05a6834ef..000000000 --- a/test/unit/identity/interaction/PromptHandler.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Operation } from '../../../../src/http/Operation'; -import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; -import { PromptHandler } from '../../../../src/identity/interaction/PromptHandler'; -import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute'; -import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; - -describe('A PromptHandler', (): void => { - const operation: Operation = { target: { path: 'http://example.com/test/' }} as any; - let oidcInteraction: Interaction; - let promptRoutes: Record>; - let handler: PromptHandler; - - beforeEach(async(): Promise => { - oidcInteraction = { prompt: { name: 'login' }} as any; - promptRoutes = { - login: { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any, - }; - handler = new PromptHandler(promptRoutes); - }); - - it('errors if there is no interaction.', async(): Promise => { - await expect(handler.handle({ operation })).rejects.toThrow(BadRequestHttpError); - }); - - it('errors if the prompt is unsupported.', async(): Promise => { - oidcInteraction.prompt.name = 'unsupported'; - await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(BadRequestHttpError); - }); - - it('throws a redirect error with the correct location.', async(): Promise => { - const error = expect.objectContaining({ - statusCode: 302, - location: 'http://example.com/idp/login/', - }); - await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(error); - }); -}); diff --git a/test/unit/identity/interaction/StaticInteractionHandler.test.ts b/test/unit/identity/interaction/StaticInteractionHandler.test.ts new file mode 100644 index 000000000..d1c69678e --- /dev/null +++ b/test/unit/identity/interaction/StaticInteractionHandler.test.ts @@ -0,0 +1,10 @@ +import { StaticInteractionHandler } from '../../../../src/identity/interaction/StaticInteractionHandler'; + +describe('A FixedInteractionHandler', (): void => { + const json = { data: 'data' }; + const handler = new StaticInteractionHandler(json); + + it('returns the given JSON as response.', async(): Promise => { + await expect(handler.handle()).resolves.toEqual({ json }); + }); +}); diff --git a/test/unit/identity/interaction/VersionHandler.test.ts b/test/unit/identity/interaction/VersionHandler.test.ts new file mode 100644 index 000000000..1737ecaf1 --- /dev/null +++ b/test/unit/identity/interaction/VersionHandler.test.ts @@ -0,0 +1,31 @@ +import type { JsonInteractionHandler } from '../../../../src/identity/interaction/JsonInteractionHandler'; +import { VersionHandler } from '../../../../src/identity/interaction/VersionHandler'; + +describe('A VersionHandler', (): void => { + let source: jest.Mocked; + let handler: VersionHandler; + + beforeEach(async(): Promise => { + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue({ json: { data: 'data' }}), + } as any; + + handler = new VersionHandler(source); + }); + + it('can handle input its source can handle.', async(): Promise => { + await expect(handler.canHandle({} as any)).resolves.toBeUndefined(); + + const error = new Error('bad data'); + source.canHandle.mockRejectedValueOnce(error); + await expect(handler.canHandle({} as any)).rejects.toThrow(error); + }); + + it('adds the API version to the output.', async(): Promise => { + await expect(handler.handle({} as any)).resolves.toEqual({ json: { + data: 'data', + version: '0.5', + }}); + }); +}); diff --git a/test/unit/identity/interaction/ViewInteractionHandler.test.ts b/test/unit/identity/interaction/ViewInteractionHandler.test.ts new file mode 100644 index 000000000..054a029e8 --- /dev/null +++ b/test/unit/identity/interaction/ViewInteractionHandler.test.ts @@ -0,0 +1,56 @@ +import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata'; +import type { JsonInteractionHandlerInput, + JsonInteractionHandler } from '../../../../src/identity/interaction/JsonInteractionHandler'; +import type { JsonView } from '../../../../src/identity/interaction/JsonView'; +import { ViewInteractionHandler } from '../../../../src/identity/interaction/ViewInteractionHandler'; +import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError'; + +describe('A BaseInteractionHandler', (): void => { + let input: JsonInteractionHandlerInput; + let source: jest.Mocked; + let handler: ViewInteractionHandler; + + beforeEach(async(): Promise => { + input = { + method: 'GET', + target: { path: 'target' }, + json: { input: 'data' }, + metadata: new RepresentationMetadata(), + }; + + source = { + getView: jest.fn().mockResolvedValue('view'), + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue('response'), + handleSafe: jest.fn(), + }; + + handler = new ViewInteractionHandler(source); + }); + + it('can only handle GET and POST requests.', async(): Promise => { + input.method = 'DELETE'; + + await expect(handler.canHandle(input)).rejects.toThrow(MethodNotAllowedHttpError); + + input.method = 'GET'; + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + + input.method = 'POST'; + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + }); + + it('returns the view on GET requests.', async(): Promise => { + input.method = 'GET'; + await expect(handler.handle(input)).resolves.toBe('view'); + expect(source.getView).toHaveBeenCalledTimes(1); + expect(source.getView).toHaveBeenLastCalledWith(input); + }); + + it('calls the handlePost function on POST requests.', async(): Promise => { + input.method = 'POST'; + await expect(handler.handle(input)).resolves.toBe('response'); + expect(source.handle).toHaveBeenCalledTimes(1); + expect(source.handle).toHaveBeenLastCalledWith(input); + }); +}); diff --git a/test/unit/identity/interaction/YupUtil.test.ts b/test/unit/identity/interaction/YupUtil.test.ts new file mode 100644 index 000000000..48f10bf0d --- /dev/null +++ b/test/unit/identity/interaction/YupUtil.test.ts @@ -0,0 +1,57 @@ +import { boolean, number, object, string } from 'yup'; +import { parseSchema, URL_SCHEMA, validateWithError } from '../../../../src/identity/interaction/YupUtil'; +import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; + +describe('YupUtil', (): void => { + describe('#URL_SCHEMA', (): void => { + it('validates URLs.', async(): Promise => { + await expect(URL_SCHEMA.isValid('https://example.com/foo')).resolves.toBe(true); + await expect(URL_SCHEMA.isValid('http://localhost:3000/foo')).resolves.toBe(true); + await expect(URL_SCHEMA.isValid('apple')).resolves.toBe(false); + await expect(URL_SCHEMA.isValid('mail@example.com')).resolves.toBe(false); + await expect(URL_SCHEMA.isValid('')).resolves.toBe(true); + await expect(URL_SCHEMA.isValid(null)).resolves.toBe(false); + }); + }); + + describe('#parseSchema', (): void => { + it('creates representations for yup schemas.', async(): Promise => { + const schema = object({ + optStr: string(), + reqStr: string().required(), + numb: number(), + bool: boolean(), + obj: object({ + key: string().required(), + obj2: object({ + nested: number(), + }), + }).required(), + }); + expect(parseSchema(schema)).toEqual({ fields: { + optStr: { type: 'string', required: false }, + reqStr: { type: 'string', required: true }, + numb: { type: 'number', required: false }, + bool: { type: 'boolean', required: false }, + obj: { type: 'object', + required: true, + fields: { + key: { type: 'string', required: true }, + obj2: { type: 'object', + required: false, + fields: { + nested: { type: 'number', required: false }, + }}, + }}, + }}); + }); + }); + + describe('#validateWithError', (): void => { + it('throws a BadRequestHttpError if there is an error.', async(): Promise => { + const schema = object({}); + await expect(validateWithError(schema, { test: 'data' })).resolves.toEqual({ test: 'data' }); + await expect(validateWithError(schema, 'test')).rejects.toThrow(BadRequestHttpError); + }); + }); +}); diff --git a/test/unit/identity/interaction/account/AccountDetailsHandler.test.ts b/test/unit/identity/interaction/account/AccountDetailsHandler.test.ts new file mode 100644 index 000000000..8a1427b5f --- /dev/null +++ b/test/unit/identity/interaction/account/AccountDetailsHandler.test.ts @@ -0,0 +1,20 @@ +import { AccountDetailsHandler } from '../../../../../src/identity/interaction/account/AccountDetailsHandler'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +describe('An AccountDetailsHandler', (): void => { + const accountId = 'id'; + const account = createAccount(); + let accountStore: jest.Mocked; + let handler: AccountDetailsHandler; + + beforeEach(async(): Promise => { + accountStore = mockAccountStore(account); + + handler = new AccountDetailsHandler(accountStore); + }); + + it('returns a JSON representation of the account.', async(): Promise => { + await expect(handler.handle({ accountId } as any)).resolves.toEqual({ json: account }); + }); +}); diff --git a/test/unit/identity/interaction/account/AccountIdRoute.test.ts b/test/unit/identity/interaction/account/AccountIdRoute.test.ts new file mode 100644 index 000000000..c5518eae5 --- /dev/null +++ b/test/unit/identity/interaction/account/AccountIdRoute.test.ts @@ -0,0 +1,11 @@ +import { BaseAccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute'; +import { + AbsolutePathInteractionRoute, +} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; + +describe('A BaseAccountIdRoute', (): void => { + it('uses the Account ID key.', async(): Promise => { + const accountIdRoute = new BaseAccountIdRoute(new AbsolutePathInteractionRoute('http://example.com/')); + expect(accountIdRoute.matchPath('http://example.com/123/')).toEqual({ accountId: '123' }); + }); +}); diff --git a/test/unit/identity/interaction/account/CreateAccountHandler.test.ts b/test/unit/identity/interaction/account/CreateAccountHandler.test.ts new file mode 100644 index 000000000..c4f5da979 --- /dev/null +++ b/test/unit/identity/interaction/account/CreateAccountHandler.test.ts @@ -0,0 +1,23 @@ +import { CreateAccountHandler } from '../../../../../src/identity/interaction/account/CreateAccountHandler'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +describe('A CreateAccountHandler', (): void => { + let accountStore: jest.Mocked; + let handler: CreateAccountHandler; + + beforeEach(async(): Promise => { + accountStore = mockAccountStore(); + handler = new CreateAccountHandler(accountStore, {} as any, {} as any); + }); + + it('has no requirements.', async(): Promise => { + await expect(handler.getView()).resolves.toEqual({ json: {}}); + }); + + it('returns the identifier of the newly created account.', async(): Promise => { + const account = createAccount('custom'); + accountStore.create.mockResolvedValueOnce(account); + await expect(handler.login()).resolves.toEqual({ json: { accountId: 'custom' }}); + }); +}); diff --git a/test/unit/identity/interaction/account/util/AccountUtil.test.ts b/test/unit/identity/interaction/account/util/AccountUtil.test.ts new file mode 100644 index 000000000..d0bce4d20 --- /dev/null +++ b/test/unit/identity/interaction/account/util/AccountUtil.test.ts @@ -0,0 +1,114 @@ +import type { Account } from '../../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../../src/identity/interaction/account/util/AccountStore'; +import { + addLoginEntry, + ensureResource, + getRequiredAccount, + safeUpdate, +} from '../../../../../../src/identity/interaction/account/util/AccountUtil'; +import { NotFoundHttpError } from '../../../../../../src/util/errors/NotFoundHttpError'; +import { createAccount, mockAccountStore } from '../../../../../util/AccountUtil'; + +describe('AccountUtil', (): void => { + const resource = 'http://example.com/.account/link'; + let account: Account; + + beforeEach(async(): Promise => { + account = createAccount(); + }); + + describe('#getRequiredAccount', (): void => { + let accountStore: jest.Mocked; + + beforeEach(async(): Promise => { + accountStore = mockAccountStore(account); + }); + + it('returns the found account.', async(): Promise => { + await expect(getRequiredAccount(accountStore, 'id')).resolves.toBe(account); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith('id'); + }); + + it('throws an error if no account was found.', async(): Promise => { + accountStore.get.mockResolvedValueOnce(undefined); + await expect(getRequiredAccount(accountStore)).rejects.toThrow(NotFoundHttpError); + }); + }); + + describe('#ensureResource', (): void => { + const data = { + 'http://example.com/pod/': resource, + 'http://example.com/other-pod/': 'http://example.com/.account/other-link', + }; + + it('returns the matching key.', async(): Promise => { + expect(ensureResource(data, resource)).toBe('http://example.com/pod/'); + }); + + it('throws a 404 if there is no input.', async(): Promise => { + expect((): any => ensureResource(undefined, resource)).toThrow(NotFoundHttpError); + expect((): any => ensureResource(data)).toThrow(NotFoundHttpError); + }); + + it('throws a 404 if there is no match.', async(): Promise => { + expect((): any => ensureResource(data, 'http://example.com/unknown/')).toThrow(NotFoundHttpError); + }); + }); + + describe('#addLoginEntry', (): void => { + it('adds the login entry.', async(): Promise => { + addLoginEntry(account, 'method', 'key', 'resource'); + expect(account.logins?.method?.key).toBe('resource'); + }); + + it('does not overwrite existing entries.', async(): Promise => { + account.logins.method = { key: 'resource' }; + addLoginEntry(account, 'method', 'key2', 'resource2'); + expect(account.logins?.method).toEqual({ key: 'resource', key2: 'resource2' }); + }); + }); + + describe('#safeUpdate', (): void => { + const oldAccount: Account = createAccount(); + let accountStore: jest.Mocked; + let operation: jest.Mock, []>; + + beforeEach(async(): Promise => { + accountStore = mockAccountStore(oldAccount); + + operation = jest.fn().mockResolvedValue('response'); + }); + + it('updates the account and calls the operation function.', async(): Promise => { + account.pods['http://example.com.pod'] = resource; + await expect(safeUpdate(account, accountStore, operation)).resolves.toBe('response'); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(account.id); + expect(accountStore.update).toHaveBeenCalledTimes(1); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(operation).toHaveBeenCalledTimes(1); + expect(account.pods['http://example.com.pod']).toBe(resource); + }); + + it('resets the account data if an error occurs.', async(): Promise => { + const error = new Error('bad data'); + operation.mockRejectedValueOnce(error); + await expect(safeUpdate(account, accountStore, operation)).rejects.toThrow(error); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(account.id); + expect(accountStore.update).toHaveBeenCalledTimes(2); + expect(accountStore.update).toHaveBeenNthCalledWith(1, account); + expect(accountStore.update).toHaveBeenNthCalledWith(2, oldAccount); + expect(operation).toHaveBeenCalledTimes(1); + expect(account.pods).toEqual({}); + }); + + it('throws a 404 if the account is unknown.', async(): Promise => { + accountStore.get.mockResolvedValueOnce(undefined); + await expect(safeUpdate(account, accountStore, operation)).rejects.toThrow(NotFoundHttpError); + expect(accountStore.update).toHaveBeenCalledTimes(0); + expect(operation).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/test/unit/identity/interaction/account/util/BaseAccountStore.test.ts b/test/unit/identity/interaction/account/util/BaseAccountStore.test.ts new file mode 100644 index 000000000..02633089f --- /dev/null +++ b/test/unit/identity/interaction/account/util/BaseAccountStore.test.ts @@ -0,0 +1,54 @@ +import type { Account } from '../../../../../../src/identity/interaction/account/util/Account'; +import { BaseAccountStore } from '../../../../../../src/identity/interaction/account/util/BaseAccountStore'; +import type { ExpiringStorage } from '../../../../../../src/storage/keyvalue/ExpiringStorage'; +import { NotFoundHttpError } from '../../../../../../src/util/errors/NotFoundHttpError'; +import { createAccount } from '../../../../../util/AccountUtil'; + +jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' })); + +describe('A BaseAccountStore', (): void => { + let account: Account; + let storage: jest.Mocked>; + let store: BaseAccountStore; + + beforeEach(async(): Promise => { + account = createAccount('4c9b88c1-7502-4107-bb79-2a3a590c7aa3'); + + storage = { + get: jest.fn().mockResolvedValue(account), + set: jest.fn(), + } as any; + + store = new BaseAccountStore(storage); + }); + + it('creates an empty account.', async(): Promise => { + await expect(store.create()).resolves.toEqual(account); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith(account.id, account, 30 * 60 * 1000); + }); + + it('stores the new data when updating.', async(): Promise => { + // This line is here just for 100% coverage + account.logins.empty = undefined; + account.logins.method = { key: 'value' }; + await expect(store.update(account)).resolves.toBeUndefined(); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith(account.id, account); + }); + + it('errors when trying to update without login methods.', async(): Promise => { + await expect(store.update(account)).rejects.toThrow('An account needs at least 1 login method.'); + expect(storage.get).toHaveBeenCalledTimes(1); + expect(storage.get).toHaveBeenLastCalledWith(account.id); + expect(storage.set).toHaveBeenCalledTimes(0); + }); + + it('throws a 404 if the account is not known when updating.', async(): Promise => { + storage.get.mockResolvedValueOnce(undefined); + await expect(store.update(account)).rejects.toThrow(NotFoundHttpError); + expect(storage.get).toHaveBeenCalledTimes(1); + expect(storage.get).toHaveBeenLastCalledWith(account.id); + expect(storage.set).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/identity/interaction/account/util/BaseCookieStore.test.ts b/test/unit/identity/interaction/account/util/BaseCookieStore.test.ts new file mode 100644 index 000000000..40d8f6441 --- /dev/null +++ b/test/unit/identity/interaction/account/util/BaseCookieStore.test.ts @@ -0,0 +1,59 @@ +import { BaseCookieStore } from '../../../../../../src/identity/interaction/account/util/BaseCookieStore'; +import type { ExpiringStorage } from '../../../../../../src/storage/keyvalue/ExpiringStorage'; + +const cookie = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3'; +jest.mock('uuid', (): any => ({ v4: (): string => cookie })); + +const now = new Date(); +jest.useFakeTimers(); +jest.setSystemTime(now); + +describe('A BaseCookieStore', (): void => { + const accountId = 'id'; + let storage: jest.Mocked>; + let store: BaseCookieStore; + + beforeEach(async(): Promise => { + storage = { + get: jest.fn().mockResolvedValue(accountId), + set: jest.fn(), + delete: jest.fn(), + } as any; + + store = new BaseCookieStore(storage); + }); + + it('can create new cookies.', async(): Promise => { + await expect(store.generate(accountId)).resolves.toBe(cookie); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith(cookie, accountId, 14 * 24 * 60 * 60 * 1000); + }); + + it('can return the matching account ID.', async(): Promise => { + await expect(store.get(cookie)).resolves.toBe(accountId); + expect(storage.get).toHaveBeenCalledTimes(1); + expect(storage.get).toHaveBeenLastCalledWith(cookie); + }); + + it('can refresh the expiration timer.', async(): Promise => { + await expect(store.refresh(cookie)).resolves.toEqual(new Date(now.getTime() + (14 * 24 * 60 * 60 * 1000))); + expect(storage.get).toHaveBeenCalledTimes(1); + expect(storage.get).toHaveBeenLastCalledWith(cookie); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith(cookie, accountId, 14 * 24 * 60 * 60 * 1000); + }); + + it('does not reset the timer if there is no match.', async(): Promise => { + storage.get.mockResolvedValueOnce(undefined); + await expect(store.refresh(cookie)).resolves.toBeUndefined(); + expect(storage.get).toHaveBeenCalledTimes(1); + expect(storage.get).toHaveBeenLastCalledWith(cookie); + expect(storage.set).toHaveBeenCalledTimes(0); + }); + + it('can delete cookies.', async(): Promise => { + await expect(store.delete(cookie)).resolves.toBeUndefined(); + expect(storage.delete).toHaveBeenCalledTimes(1); + expect(storage.delete).toHaveBeenLastCalledWith(cookie); + }); +}); diff --git a/test/unit/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.test.ts b/test/unit/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.test.ts new file mode 100644 index 000000000..1c32ba5b5 --- /dev/null +++ b/test/unit/identity/interaction/client-credentials/ClientCredentialsAdapterFactory.test.ts @@ -0,0 +1,113 @@ +import type { Adapter } from 'oidc-provider'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { + ClientCredentialsAdapter, ClientCredentialsAdapterFactory, +} from '../../../../../src/identity/interaction/client-credentials/ClientCredentialsAdapterFactory'; +import type { + ClientCredentialsStore, +} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; +import type { AdapterFactory } from '../../../../../src/identity/storage/AdapterFactory'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +describe('A ClientCredentialsAdapterFactory', (): void => { + let credentialsStore: jest.Mocked; + let accountStore: jest.Mocked; + let sourceAdapter: jest.Mocked; + let sourceFactory: jest.Mocked; + let adapter: ClientCredentialsAdapter; + let factory: ClientCredentialsAdapterFactory; + + beforeEach(async(): Promise => { + sourceAdapter = { + find: jest.fn(), + } as any; + + sourceFactory = { + createStorageAdapter: jest.fn().mockReturnValue(sourceAdapter), + }; + + accountStore = mockAccountStore(); + + credentialsStore = { + get: jest.fn(), + delete: jest.fn(), + } as any; + + adapter = new ClientCredentialsAdapter('Client', sourceAdapter, accountStore, credentialsStore); + factory = new ClientCredentialsAdapterFactory(sourceFactory, accountStore, credentialsStore); + }); + + it('calls the source factory when creating a new Adapter.', async(): Promise => { + expect(factory.createStorageAdapter('Name')).toBeInstanceOf(ClientCredentialsAdapter); + expect(sourceFactory.createStorageAdapter).toHaveBeenCalledTimes(1); + expect(sourceFactory.createStorageAdapter).toHaveBeenLastCalledWith('Name'); + }); + + it('returns the result from the source.', async(): Promise => { + sourceAdapter.find.mockResolvedValue({ payload: 'payload' }); + await expect(adapter.find('id')).resolves.toEqual({ payload: 'payload' }); + expect(sourceAdapter.find).toHaveBeenCalledTimes(1); + expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); + expect(credentialsStore.get).toHaveBeenCalledTimes(0); + expect(accountStore.get).toHaveBeenCalledTimes(0); + }); + + it('tries to find a matching client credentials token if no result was found.', async(): Promise => { + await expect(adapter.find('id')).resolves.toBeUndefined(); + expect(sourceAdapter.find).toHaveBeenCalledTimes(1); + expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); + expect(credentialsStore.get).toHaveBeenCalledTimes(1); + expect(credentialsStore.get).toHaveBeenLastCalledWith('id'); + expect(accountStore.get).toHaveBeenCalledTimes(0); + }); + + it('returns no result if there is no matching account.', async(): Promise => { + accountStore.get.mockResolvedValueOnce(undefined); + credentialsStore.get.mockResolvedValue({ secret: 'super_secret', webId: 'http://example.com/foo#me', accountId: 'accountId' }); + await expect(adapter.find('id')).resolves.toBeUndefined(); + expect(sourceAdapter.find).toHaveBeenCalledTimes(1); + expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); + expect(credentialsStore.get).toHaveBeenCalledTimes(1); + expect(credentialsStore.get).toHaveBeenLastCalledWith('id'); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith('accountId'); + }); + + it('returns no result if the WebID is not linked to the account and deletes the token.', async(): Promise => { + const account = createAccount(); + accountStore.get.mockResolvedValueOnce(account); + credentialsStore.get.mockResolvedValue({ secret: 'super_secret', webId: 'http://example.com/foo#me', accountId: 'accountId' }); + await expect(adapter.find('id')).resolves.toBeUndefined(); + expect(sourceAdapter.find).toHaveBeenCalledTimes(1); + expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); + expect(credentialsStore.get).toHaveBeenCalledTimes(1); + expect(credentialsStore.get).toHaveBeenLastCalledWith('id'); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith('accountId'); + expect(credentialsStore.delete).toHaveBeenCalledTimes(1); + expect(credentialsStore.delete).toHaveBeenLastCalledWith('id', account); + }); + + it('returns valid client_credentials Client metadata if a matching token was found.', async(): Promise => { + const webId = 'http://example.com/foo#me'; + const account = createAccount(); + account.webIds[webId] = 'resource'; + accountStore.get.mockResolvedValueOnce(account); + credentialsStore.get.mockResolvedValue({ secret: 'super_secret', webId, accountId: 'accountId' }); + /* eslint-disable @typescript-eslint/naming-convention */ + await expect(adapter.find('id')).resolves.toEqual({ + client_id: 'id', + client_secret: 'super_secret', + grant_types: [ 'client_credentials' ], + redirect_uris: [], + response_types: [], + }); + /* eslint-enable @typescript-eslint/naming-convention */ + expect(sourceAdapter.find).toHaveBeenCalledTimes(1); + expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); + expect(credentialsStore.get).toHaveBeenCalledTimes(1); + expect(credentialsStore.get).toHaveBeenLastCalledWith('id'); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith('accountId'); + }); +}); diff --git a/test/unit/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.test.ts b/test/unit/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.test.ts new file mode 100644 index 000000000..5d4135ccd --- /dev/null +++ b/test/unit/identity/interaction/client-credentials/ClientCredentialsDetailsHandler.test.ts @@ -0,0 +1,59 @@ +import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { + ClientCredentialsDetailsHandler, +} from '../../../../../src/identity/interaction/client-credentials/ClientCredentialsDetailsHandler'; +import type { + ClientCredentialsStore, +} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; +import { InternalServerError } from '../../../../../src/util/errors/InternalServerError'; +import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +describe('A ClientCredentialsDetailsHandler', (): void => { + const webId = 'http://example.com/card#me'; + const id = 'token_id'; + const target = { path: 'http://example.com/.account/my_token' }; + let account: Account; + let accountStore: jest.Mocked; + let clientCredentialsStore: jest.Mocked; + let handler: ClientCredentialsDetailsHandler; + + beforeEach(async(): Promise => { + account = createAccount(); + account.clientCredentials[id] = target.path; + + accountStore = mockAccountStore(account); + + clientCredentialsStore = { + get: jest.fn().mockResolvedValue({ webId, accountId: account.id, secret: 'ssh!' }), + } as any; + + handler = new ClientCredentialsDetailsHandler(accountStore, clientCredentialsStore); + }); + + it('returns the necessary information.', async(): Promise => { + await expect(handler.handle({ target, accountId: account.id } as any)).resolves.toEqual({ json: { id, webId }}); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(account.id); + expect(clientCredentialsStore.get).toHaveBeenCalledTimes(1); + expect(clientCredentialsStore.get).toHaveBeenLastCalledWith(id); + }); + + it('throws a 404 if there is no such token.', async(): Promise => { + delete account.clientCredentials[id]; + await expect(handler.handle({ target, accountId: account.id } as any)).rejects.toThrow(NotFoundHttpError); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(account.id); + expect(clientCredentialsStore.get).toHaveBeenCalledTimes(0); + }); + + it('throws an error if there is a data mismatch.', async(): Promise => { + clientCredentialsStore.get.mockResolvedValueOnce(undefined); + await expect(handler.handle({ target, accountId: account.id } as any)).rejects.toThrow(InternalServerError); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(account.id); + expect(clientCredentialsStore.get).toHaveBeenCalledTimes(1); + expect(clientCredentialsStore.get).toHaveBeenLastCalledWith(id); + }); +}); diff --git a/test/unit/identity/interaction/client-credentials/ClientCredentialsIdRoute.test.ts b/test/unit/identity/interaction/client-credentials/ClientCredentialsIdRoute.test.ts new file mode 100644 index 000000000..5d7505d02 --- /dev/null +++ b/test/unit/identity/interaction/client-credentials/ClientCredentialsIdRoute.test.ts @@ -0,0 +1,17 @@ +import { BaseAccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute'; +import { + BaseClientCredentialsIdRoute, +} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute'; +import { + AbsolutePathInteractionRoute, +} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; + +describe('A BaseClientCredentialsIdRoute', (): void => { + it('uses the Credentials ID key.', async(): Promise => { + const credentialsIdRoute = new BaseClientCredentialsIdRoute(new BaseAccountIdRoute( + new AbsolutePathInteractionRoute('http://example.com/'), + )); + expect(credentialsIdRoute.matchPath('http://example.com/123/456/')) + .toEqual({ accountId: '123', clientCredentialsId: '456' }); + }); +}); diff --git a/test/unit/identity/interaction/client-credentials/CreateClientCredentialsHandler.test.ts b/test/unit/identity/interaction/client-credentials/CreateClientCredentialsHandler.test.ts new file mode 100644 index 000000000..237512e54 --- /dev/null +++ b/test/unit/identity/interaction/client-credentials/CreateClientCredentialsHandler.test.ts @@ -0,0 +1,64 @@ +import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { + CreateClientCredentialsHandler, +} from '../../../../../src/identity/interaction/client-credentials/CreateClientCredentialsHandler'; +import type { + ClientCredentialsStore, +} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' })); + +describe('A CreateClientCredentialsHandler', (): void => { + let account: Account; + const json = { + webId: 'http://example.com/foo#me', + name: 'token', + }; + let accountStore: jest.Mocked; + let clientCredentialsStore: jest.Mocked; + let handler: CreateClientCredentialsHandler; + + beforeEach(async(): Promise => { + account = createAccount(); + + accountStore = mockAccountStore(account); + + clientCredentialsStore = { + add: jest.fn().mockReturnValue({ secret: 'secret', resource: 'resource' }), + } as any; + + handler = new CreateClientCredentialsHandler(accountStore, clientCredentialsStore); + }); + + it('requires specific input fields.', async(): Promise => { + await expect(handler.getView()).resolves.toEqual({ + json: { + fields: { + name: { + required: false, + type: 'string', + }, + webId: { + required: true, + type: 'string', + }, + }, + }, + }); + }); + + it('creates a new token based on the provided settings.', async(): Promise => { + await expect(handler.handle({ accountId: account.id, json } as any)).resolves.toEqual({ + json: { id: 'token_4c9b88c1-7502-4107-bb79-2a3a590c7aa3', secret: 'secret', resource: 'resource' }, + }); + }); + + it('allows token names to be empty.', async(): Promise => { + await expect(handler.handle({ accountId: account.id, json: { webId: 'http://example.com/foo#me' }} as any)) + .resolves.toEqual({ + json: { id: '_4c9b88c1-7502-4107-bb79-2a3a590c7aa3', secret: 'secret', resource: 'resource' }, + }); + }); +}); diff --git a/test/unit/identity/interaction/client-credentials/DeleteClientCredentialsHandler.test.ts b/test/unit/identity/interaction/client-credentials/DeleteClientCredentialsHandler.test.ts new file mode 100644 index 000000000..45d3a0a87 --- /dev/null +++ b/test/unit/identity/interaction/client-credentials/DeleteClientCredentialsHandler.test.ts @@ -0,0 +1,48 @@ +import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { + DeleteClientCredentialsHandler, +} from '../../../../../src/identity/interaction/client-credentials/DeleteClientCredentialsHandler'; +import type { + ClientCredentialsStore, +} from '../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; +import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +describe('A DeleteClientCredentialsHandler', (): void => { + let account: Account; + const id = 'token_id'; + const target = { path: 'http://example.com/.account/my_token' }; + let accountStore: jest.Mocked; + let clientCredentialsStore: jest.Mocked; + let handler: DeleteClientCredentialsHandler; + + beforeEach(async(): Promise => { + account = createAccount(); + account.clientCredentials[id] = target.path; + + accountStore = mockAccountStore(account); + + clientCredentialsStore = { + delete: jest.fn(), + } as any; + + handler = new DeleteClientCredentialsHandler(accountStore, clientCredentialsStore); + }); + + it('deletes the token.', async(): Promise => { + await expect(handler.handle({ target, accountId: account.id } as any)).resolves.toEqual({ json: {}}); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(account.id); + expect(clientCredentialsStore.delete).toHaveBeenCalledTimes(1); + expect(clientCredentialsStore.delete).toHaveBeenLastCalledWith(id, account); + }); + + it('throws a 404 if there is no such token.', async(): Promise => { + delete account.clientCredentials[id]; + await expect(handler.handle({ target, accountId: account.id } as any)).rejects.toThrow(NotFoundHttpError); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(account.id); + expect(clientCredentialsStore.delete).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/identity/interaction/client-credentials/util/BaseClientCredentialsStore.test.ts b/test/unit/identity/interaction/client-credentials/util/BaseClientCredentialsStore.test.ts new file mode 100644 index 000000000..48da777a0 --- /dev/null +++ b/test/unit/identity/interaction/client-credentials/util/BaseClientCredentialsStore.test.ts @@ -0,0 +1,91 @@ +import type { Account } from '../../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../../src/identity/interaction/account/util/AccountStore'; +import { + BaseClientCredentialsStore, +} from '../../../../../../src/identity/interaction/client-credentials/util/BaseClientCredentialsStore'; +import type { + ClientCredentialsIdRoute, +} from '../../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsIdRoute'; +import type { + ClientCredentials, +} from '../../../../../../src/identity/interaction/client-credentials/util/ClientCredentialsStore'; +import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; +import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError'; +import { createAccount, mockAccountStore } from '../../../../../util/AccountUtil'; + +const secret = 'verylongstringof64bytes'; +jest.mock('crypto', (): any => ({ randomBytes: (): string => secret })); + +describe('A BaseClientCredentialsStore', (): void => { + const webId = 'http://example.com/card#me'; + let account: Account; + const route: ClientCredentialsIdRoute = { + getPath: (): string => 'http://example.com/.account/resource', + matchPath: (): any => ({}), + }; + let accountStore: jest.Mocked; + let storage: jest.Mocked>; + let store: BaseClientCredentialsStore; + + beforeEach(async(): Promise => { + account = createAccount(); + account.webIds[webId] = 'resource'; + + // Different account object so `safeUpdate` can be tested correctly + const oldAccount = createAccount(); + oldAccount.webIds[webId] = 'resource'; + accountStore = mockAccountStore(oldAccount); + + storage = { + get: jest.fn().mockResolvedValue({ accountId: account.id, webId, secret: 'secret' }), + set: jest.fn(), + delete: jest.fn(), + } as any; + + store = new BaseClientCredentialsStore(route, accountStore, storage); + }); + + it('returns the token it finds.', async(): Promise => { + await expect(store.get('credentialsId')).resolves.toEqual({ accountId: account.id, webId, secret: 'secret' }); + expect(storage.get).toHaveBeenCalledTimes(1); + expect(storage.get).toHaveBeenLastCalledWith('credentialsId'); + }); + + it('creates a new token and adds it to the account.', async(): Promise => { + await expect(store.add('credentialsId', webId, account)).resolves + .toEqual({ secret, resource: 'http://example.com/.account/resource' }); + expect(account.clientCredentials.credentialsId).toBe('http://example.com/.account/resource'); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith('credentialsId', { secret, accountId: account.id, webId }); + expect(accountStore.update).toHaveBeenCalledTimes(1); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + }); + + it('errors if the WebID is not registered to the account.', async(): Promise => { + delete account.webIds[webId]; + await expect(store.add('credentialsId', webId, account)).rejects.toThrow(BadRequestHttpError); + expect(storage.set).toHaveBeenCalledTimes(0); + expect(accountStore.update).toHaveBeenCalledTimes(0); + expect(account.clientCredentials).toEqual({}); + }); + + it('does not update the account if something goes wrong.', async(): Promise => { + storage.set.mockRejectedValueOnce(new Error('bad data')); + await expect(store.add('credentialsId', webId, account)).rejects.toThrow('bad data'); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith('credentialsId', { secret, accountId: account.id, webId }); + expect(accountStore.update).toHaveBeenCalledTimes(2); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(account.clientCredentials).toEqual({}); + }); + + it('can delete tokens.', async(): Promise => { + account.clientCredentials.credentialsId = 'resource'; + await expect(store.delete('credentialsId', account)).resolves.toBeUndefined(); + expect(storage.delete).toHaveBeenCalledTimes(1); + expect(storage.delete).toHaveBeenLastCalledWith('credentialsId'); + expect(accountStore.update).toHaveBeenCalledTimes(1); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(account.clientCredentials).toEqual({}); + }); +}); diff --git a/test/unit/identity/interaction/email-password/EmailPasswordUtil.test.ts b/test/unit/identity/interaction/email-password/EmailPasswordUtil.test.ts deleted file mode 100644 index dbcc7c454..000000000 --- a/test/unit/identity/interaction/email-password/EmailPasswordUtil.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - assertPassword, -} from '../../../../../src/identity/interaction/email-password/EmailPasswordUtil'; - -describe('EmailPasswordUtil', (): void => { - describe('#assertPassword', (): void => { - it('validates the password against the confirmPassword.', async(): Promise => { - expect((): void => assertPassword(undefined, undefined)).toThrow('Please enter a password.'); - expect((): void => assertPassword([], undefined)).toThrow('Please enter a password.'); - expect((): void => assertPassword('password', undefined)).toThrow('Please confirm your password.'); - expect((): void => assertPassword('password', [])).toThrow('Please confirm your password.'); - expect((): void => assertPassword('password', 'other')).toThrow('Your password and confirmation did not match'); - expect(assertPassword('password', 'password')).toBeUndefined(); - }); - }); -}); diff --git a/test/unit/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.test.ts b/test/unit/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.test.ts deleted file mode 100644 index b56281040..000000000 --- a/test/unit/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - ClientCredentialsAdapter, - ClientCredentialsAdapterFactory, -} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory'; -import type { - ClientCredentials, -} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory'; -import type { AdapterFactory } from '../../../../../../src/identity/storage/AdapterFactory'; -import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; -import type { Adapter } from '../../../../../../templates/types/oidc-provider'; - -describe('A ClientCredentialsAdapterFactory', (): void => { - let storage: jest.Mocked>; - let sourceAdapter: jest.Mocked; - let sourceFactory: jest.Mocked; - let adapter: ClientCredentialsAdapter; - let factory: ClientCredentialsAdapterFactory; - - beforeEach(async(): Promise => { - storage = { - get: jest.fn(), - } as any; - - sourceAdapter = { - find: jest.fn(), - } as any; - - sourceFactory = { - createStorageAdapter: jest.fn().mockReturnValue(sourceAdapter), - }; - - adapter = new ClientCredentialsAdapter('Client', sourceAdapter, storage); - factory = new ClientCredentialsAdapterFactory(sourceFactory, storage); - }); - - it('calls the source factory when creating a new Adapter.', async(): Promise => { - expect(factory.createStorageAdapter('Name')).toBeInstanceOf(ClientCredentialsAdapter); - expect(sourceFactory.createStorageAdapter).toHaveBeenCalledTimes(1); - expect(sourceFactory.createStorageAdapter).toHaveBeenLastCalledWith('Name'); - }); - - it('returns the result from the source.', async(): Promise => { - sourceAdapter.find.mockResolvedValue({ payload: 'payload' }); - await expect(adapter.find('id')).resolves.toEqual({ payload: 'payload' }); - expect(sourceAdapter.find).toHaveBeenCalledTimes(1); - expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); - expect(storage.get).toHaveBeenCalledTimes(0); - }); - - it('tries to find a matching client credentials token if no result was found.', async(): Promise => { - await expect(adapter.find('id')).resolves.toBeUndefined(); - expect(sourceAdapter.find).toHaveBeenCalledTimes(1); - expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); - expect(storage.get).toHaveBeenCalledTimes(1); - expect(storage.get).toHaveBeenLastCalledWith('id'); - }); - - it('returns valid client_credentials Client metadata if a matching token was found.', async(): Promise => { - storage.get.mockResolvedValue({ secret: 'super_secret', webId: 'http://example.com/foo#me' }); - /* eslint-disable @typescript-eslint/naming-convention */ - await expect(adapter.find('id')).resolves.toEqual({ - client_id: 'id', - client_secret: 'super_secret', - grant_types: [ 'client_credentials' ], - redirect_uris: [], - response_types: [], - }); - /* eslint-enable @typescript-eslint/naming-convention */ - expect(sourceAdapter.find).toHaveBeenCalledTimes(1); - expect(sourceAdapter.find).toHaveBeenLastCalledWith('id'); - expect(storage.get).toHaveBeenCalledTimes(1); - expect(storage.get).toHaveBeenLastCalledWith('id'); - }); -}); diff --git a/test/unit/identity/interaction/email-password/credentials/CreateCredentialsHandler.test.ts b/test/unit/identity/interaction/email-password/credentials/CreateCredentialsHandler.test.ts deleted file mode 100644 index 5018f70c0..000000000 --- a/test/unit/identity/interaction/email-password/credentials/CreateCredentialsHandler.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { Operation } from '../../../../../../src/http/Operation'; -import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation'; -import type { - ClientCredentials, -} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory'; -import { - CreateCredentialsHandler, -} from '../../../../../../src/identity/interaction/email-password/credentials/CreateCredentialsHandler'; -import type { - CredentialsHandlerBody, -} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler'; -import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; -import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes'; -import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError'; -import { NotImplementedHttpError } from '../../../../../../src/util/errors/NotImplementedHttpError'; -import { readJsonStream } from '../../../../../../src/util/StreamUtil'; - -describe('A CreateCredentialsHandler', (): void => { - let operation: Operation; - let body: CredentialsHandlerBody; - let accountStore: jest.Mocked; - let credentialStorage: jest.Mocked>; - let handler: CreateCredentialsHandler; - - beforeEach(async(): Promise => { - operation = { - method: 'POST', - body: new BasicRepresentation(), - target: { path: 'http://example.com/foo' }, - preferences: {}, - }; - - body = { - email: 'test@example.com', - webId: 'http://example.com/foo#me', - name: 'token', - }; - - accountStore = { - getSettings: jest.fn().mockResolvedValue({ useIdp: true, clientCredentials: []}), - updateSettings: jest.fn(), - } as any; - - credentialStorage = { - set: jest.fn(), - } as any; - - handler = new CreateCredentialsHandler(accountStore, credentialStorage); - }); - - it('only supports bodies with a name entry.', async(): Promise => { - await expect(handler.canHandle({ operation, body })).resolves.toBeUndefined(); - delete body.name; - await expect(handler.canHandle({ operation, body })).rejects.toThrow(NotImplementedHttpError); - }); - - it('rejects requests for accounts not using the IDP.', async(): Promise => { - accountStore.getSettings.mockResolvedValue({ useIdp: false }); - await expect(handler.handle({ operation, body })).rejects.toThrow(BadRequestHttpError); - }); - - it('creates a new credential token.', async(): Promise => { - const response = await handler.handle({ operation, body }); - expect(response.metadata.contentType).toBe(APPLICATION_JSON); - const { id, secret } = await readJsonStream(response.data); - expect(id).toMatch(/^token_/u); - expect(credentialStorage.set).toHaveBeenCalledTimes(1); - expect(credentialStorage.set).toHaveBeenLastCalledWith(id, { webId: body.webId, secret }); - expect(accountStore.getSettings).toHaveBeenCalledTimes(1); - expect(accountStore.getSettings).toHaveBeenLastCalledWith(body.webId); - expect(accountStore.updateSettings).toHaveBeenCalledTimes(1); - expect(accountStore.updateSettings) - .toHaveBeenLastCalledWith(body.webId, { useIdp: true, clientCredentials: [ id ]}); - }); - - it('can handle account settings with undefined client credentials.', async(): Promise => { - accountStore.getSettings.mockResolvedValue({ useIdp: true }); - const response = await handler.handle({ operation, body }); - expect(response.metadata.contentType).toBe(APPLICATION_JSON); - const { id, secret } = await readJsonStream(response.data); - expect(id).toMatch(/^token_/u); - expect(credentialStorage.set).toHaveBeenCalledTimes(1); - expect(credentialStorage.set).toHaveBeenLastCalledWith(id, { webId: body.webId, secret }); - expect(accountStore.getSettings).toHaveBeenCalledTimes(1); - expect(accountStore.getSettings).toHaveBeenLastCalledWith(body.webId); - expect(accountStore.updateSettings).toHaveBeenCalledTimes(1); - expect(accountStore.updateSettings) - .toHaveBeenLastCalledWith(body.webId, { useIdp: true, clientCredentials: [ id ]}); - }); -}); diff --git a/test/unit/identity/interaction/email-password/credentials/DeleteCredentialsHandler.test.ts b/test/unit/identity/interaction/email-password/credentials/DeleteCredentialsHandler.test.ts deleted file mode 100644 index 932f99b28..000000000 --- a/test/unit/identity/interaction/email-password/credentials/DeleteCredentialsHandler.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { Operation } from '../../../../../../src/http/Operation'; -import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation'; -import type { - ClientCredentials, -} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory'; -import type { - CredentialsHandlerBody, -} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler'; -import { - DeleteCredentialsHandler, -} from '../../../../../../src/identity/interaction/email-password/credentials/DeleteCredentialsHandler'; -import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; -import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes'; -import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError'; -import { NotImplementedHttpError } from '../../../../../../src/util/errors/NotImplementedHttpError'; - -describe('A DeleteCredentialsHandler', (): void => { - let operation: Operation; - const id = 'token_id'; - let body: CredentialsHandlerBody; - let accountStore: jest.Mocked; - let credentialStorage: jest.Mocked>; - let handler: DeleteCredentialsHandler; - - beforeEach(async(): Promise => { - operation = { - method: 'POST', - body: new BasicRepresentation(), - target: { path: 'http://example.com/foo' }, - preferences: {}, - }; - - body = { - email: 'test@example.com', - webId: 'http://example.com/foo#me', - delete: id, - }; - - accountStore = { - getSettings: jest.fn().mockResolvedValue({ clientCredentials: [ id ]}), - updateSettings: jest.fn(), - } as any; - - credentialStorage = { - delete: jest.fn(), - } as any; - - handler = new DeleteCredentialsHandler(accountStore, credentialStorage); - }); - - it('only supports bodies with a delete entry.', async(): Promise => { - await expect(handler.canHandle({ operation, body })).resolves.toBeUndefined(); - delete body.delete; - await expect(handler.canHandle({ operation, body })).rejects.toThrow(NotImplementedHttpError); - }); - - it('deletes the token.', async(): Promise => { - const response = await handler.handle({ operation, body }); - expect(response.metadata.contentType).toBe(APPLICATION_JSON); - expect(credentialStorage.delete).toHaveBeenCalledTimes(1); - expect(credentialStorage.delete).toHaveBeenLastCalledWith(id); - expect(accountStore.getSettings).toHaveBeenCalledTimes(1); - expect(accountStore.getSettings).toHaveBeenLastCalledWith(body.webId); - expect(accountStore.updateSettings).toHaveBeenCalledTimes(1); - expect(accountStore.updateSettings).toHaveBeenLastCalledWith(body.webId, { clientCredentials: []}); - }); - - it('errors if the account has no such token.', async(): Promise => { - accountStore.getSettings.mockResolvedValue({ useIdp: true, clientCredentials: []}); - await expect(handler.handle({ operation, body })).rejects.toThrow(BadRequestHttpError); - - accountStore.getSettings.mockResolvedValue({ useIdp: true }); - await expect(handler.handle({ operation, body })).rejects.toThrow(BadRequestHttpError); - }); -}); diff --git a/test/unit/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.test.ts b/test/unit/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.test.ts deleted file mode 100644 index 19bb17d59..000000000 --- a/test/unit/identity/interaction/email-password/credentials/EmailPasswordAuthorizer.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { Operation } from '../../../../../../src/http/Operation'; -import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation'; -import type { Representation } from '../../../../../../src/http/representation/Representation'; -import type { - CredentialsHandler, -} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler'; -import { - EmailPasswordAuthorizer, -} from '../../../../../../src/identity/interaction/email-password/credentials/EmailPasswordAuthorizer'; -import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes'; -import { MethodNotAllowedHttpError } from '../../../../../../src/util/errors/MethodNotAllowedHttpError'; - -describe('An EmailPasswordAuthorizer', (): void => { - const email = 'test@example.com'; - const password = 'super_secret'; - const webId = 'http://example.com/profile#me'; - let operation: Operation; - let response: Representation; - let accountStore: jest.Mocked; - let source: jest.Mocked; - let handler: EmailPasswordAuthorizer; - - beforeEach(async(): Promise => { - operation = { - method: 'POST', - body: new BasicRepresentation(JSON.stringify({ email, password }), APPLICATION_JSON), - target: { path: 'http://example.com/foo' }, - preferences: {}, - }; - - response = new BasicRepresentation(); - - accountStore = { - authenticate: jest.fn().mockResolvedValue(webId), - } as any; - - source = { - handleSafe: jest.fn().mockResolvedValue(response), - } as any; - - handler = new EmailPasswordAuthorizer(accountStore, source); - }); - - it('requires POST methods.', async(): Promise => { - operation.method = 'GET'; - await expect(handler.handle({ operation })).rejects.toThrow(MethodNotAllowedHttpError); - }); - - it('calls the source after validation.', async(): Promise => { - await expect(handler.handle({ operation })).resolves.toBe(response); - expect(accountStore.authenticate).toHaveBeenCalledTimes(1); - expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, password); - expect(source.handleSafe).toHaveBeenCalledTimes(1); - expect(source.handleSafe).toHaveBeenLastCalledWith({ operation, body: { email, webId }}); - }); -}); diff --git a/test/unit/identity/interaction/email-password/credentials/ListCredentialsHandler.test.ts b/test/unit/identity/interaction/email-password/credentials/ListCredentialsHandler.test.ts deleted file mode 100644 index e01c6928d..000000000 --- a/test/unit/identity/interaction/email-password/credentials/ListCredentialsHandler.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Operation } from '../../../../../../src/http/Operation'; -import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation'; -import type { - CredentialsHandlerBody, -} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler'; -import { - ListCredentialsHandler, -} from '../../../../../../src/identity/interaction/email-password/credentials/ListCredentialsHandler'; -import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes'; -import { readJsonStream } from '../../../../../../src/util/StreamUtil'; - -describe('A ListCredentialsHandler', (): void => { - let operation: Operation; - const id = 'token_id'; - let body: CredentialsHandlerBody; - let accountStore: jest.Mocked; - let handler: ListCredentialsHandler; - - beforeEach(async(): Promise => { - operation = { - method: 'POST', - body: new BasicRepresentation(), - target: { path: 'http://example.com/foo' }, - preferences: {}, - }; - - body = { - email: 'test@example.com', - webId: 'http://example.com/foo#me', - delete: id, - }; - - accountStore = { - getSettings: jest.fn().mockResolvedValue({ clientCredentials: [ id ]}), - updateSettings: jest.fn(), - } as any; - - handler = new ListCredentialsHandler(accountStore); - }); - - it('lists all tokens.', async(): Promise => { - const response = await handler.handle({ operation, body }); - expect(response).toBeDefined(); - expect(response.metadata.contentType).toEqual(APPLICATION_JSON); - const list = await readJsonStream(response.data); - expect(list).toEqual([ id ]); - }); - - it('returns an empty array if there are no tokens.', async(): Promise => { - accountStore.getSettings.mockResolvedValue({ useIdp: true }); - const response = await handler.handle({ operation, body }); - expect(response).toBeDefined(); - expect(response.metadata.contentType).toEqual(APPLICATION_JSON); - const list = await readJsonStream(response.data); - expect(list).toEqual([]); - }); -}); diff --git a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts deleted file mode 100644 index 70d495fed..000000000 --- a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { Operation } from '../../../../../../src/http/Operation'; -import { - ForgotPasswordHandler, -} from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler'; -import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import type { EmailSender } from '../../../../../../src/identity/interaction/email-password/util/EmailSender'; -import type { InteractionRoute } from '../../../../../../src/identity/interaction/routing/InteractionRoute'; -import { readJsonStream } from '../../../../../../src/util/StreamUtil'; -import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine'; -import { createPostJsonOperation } from './Util'; - -describe('A ForgotPasswordHandler', (): void => { - let operation: Operation; - const email = 'test@test.email'; - const recordId = '123456'; - const html = `Reset Password`; - let accountStore: AccountStore; - let templateEngine: TemplateEngine<{ resetLink: string }>; - let resetRoute: jest.Mocked; - let emailSender: EmailSender; - let handler: ForgotPasswordHandler; - - beforeEach(async(): Promise => { - operation = createPostJsonOperation({ email }); - - accountStore = { - generateForgotPasswordRecord: jest.fn().mockResolvedValue(recordId), - } as any; - - templateEngine = { - handleSafe: jest.fn().mockResolvedValue(html), - } as any; - - resetRoute = { - getPath: jest.fn().mockReturnValue('http://test.com/base/idp/resetpassword/'), - } as any; - - emailSender = { - handleSafe: jest.fn(), - } as any; - - handler = new ForgotPasswordHandler({ - accountStore, - templateEngine, - emailSender, - resetRoute, - }); - }); - - it('errors on non-string emails.', async(): Promise => { - operation = createPostJsonOperation({}); - await expect(handler.handle({ operation })).rejects.toThrow('Email required'); - operation = createPostJsonOperation({ email: [ 'email', 'email2' ]}); - await expect(handler.handle({ operation })).rejects.toThrow('Email required'); - }); - - it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise => { - (accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error'); - const result = await handler.handle({ operation }); - await expect(readJsonStream(result.data)).resolves.toEqual({ email }); - expect(emailSender.handleSafe).toHaveBeenCalledTimes(0); - }); - - it('sends a mail if a ForgotPassword record could be generated.', async(): Promise => { - const result = await handler.handle({ operation }); - await expect(readJsonStream(result.data)).resolves.toEqual({ email }); - expect(result.metadata.contentType).toBe('application/json'); - expect(emailSender.handleSafe).toHaveBeenCalledTimes(1); - expect(emailSender.handleSafe).toHaveBeenLastCalledWith({ - recipient: email, - subject: 'Reset your password', - text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/?rid=${recordId}`, - html, - }); - }); -}); diff --git a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts deleted file mode 100644 index 6053562d4..000000000 --- a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { LoginHandler } from '../../../../../../src/identity/interaction/email-password/handler/LoginHandler'; -import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import type { - Interaction, - InteractionHandlerInput, -} from '../../../../../../src/identity/interaction/InteractionHandler'; -import { FoundHttpError } from '../../../../../../src/util/errors/FoundHttpError'; -import { createPostJsonOperation } from './Util'; - -describe('A LoginHandler', (): void => { - const webId = 'http://alice.test.com/card#me'; - const email = 'alice@test.email'; - let oidcInteraction: jest.Mocked; - let input: Required; - let accountStore: jest.Mocked; - let handler: LoginHandler; - - beforeEach(async(): Promise => { - oidcInteraction = { - exp: 123456, - save: jest.fn(), - } as any; - - input = { oidcInteraction } as any; - - accountStore = { - authenticate: jest.fn().mockResolvedValue(webId), - getSettings: jest.fn().mockResolvedValue({ useIdp: true }), - } as any; - - handler = new LoginHandler(accountStore); - }); - it('errors if no oidcInteraction is defined on POST requests.', async(): Promise => { - const error = expect.objectContaining({ - statusCode: 400, - message: 'This action can only be performed as part of an OIDC authentication flow.', - errorCode: 'E0002', - }); - await expect(handler.canHandle({ operation: createPostJsonOperation({}) })).rejects.toThrow(error); - - await expect(handler.canHandle({ operation: createPostJsonOperation({}), oidcInteraction })) - .resolves.toBeUndefined(); - }); - - it('errors on invalid emails.', async(): Promise => { - input.operation = createPostJsonOperation({}); - await expect(handler.handle(input)).rejects.toThrow('Email required'); - input.operation = createPostJsonOperation({ email: [ 'a', 'b' ]}); - await expect(handler.handle(input)).rejects.toThrow('Email required'); - }); - - it('errors on invalid passwords.', async(): Promise => { - input.operation = createPostJsonOperation({ email }); - await expect(handler.handle(input)).rejects.toThrow('Password required'); - input.operation = createPostJsonOperation({ email, password: [ 'a', 'b' ]}); - await expect(handler.handle(input)).rejects.toThrow('Password required'); - }); - - it('throws an error if there is a problem.', async(): Promise => { - input.operation = createPostJsonOperation({ email, password: 'password!' }); - accountStore.authenticate.mockRejectedValueOnce(new Error('auth failed!')); - await expect(handler.handle(input)).rejects.toThrow('auth failed!'); - }); - - it('throws an error if the account does not have the correct settings.', async(): Promise => { - input.operation = createPostJsonOperation({ email, password: 'password!' }); - accountStore.getSettings.mockResolvedValueOnce({ useIdp: false, clientCredentials: []}); - await expect(handler.handle(input)) - .rejects.toThrow('This server is not an identity provider for this account.'); - }); - - it('returns the generated redirect URL.', async(): Promise => { - input.operation = createPostJsonOperation({ email, password: 'password!' }); - await expect(handler.handle(input)).rejects.toThrow(FoundHttpError); - - expect(accountStore.authenticate).toHaveBeenCalledTimes(1); - expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!'); - expect(oidcInteraction.save).toHaveBeenCalledTimes(1); - expect(oidcInteraction.result).toEqual({ login: { accountId: webId, remember: false }}); - }); -}); diff --git a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts deleted file mode 100644 index 52ff1c0e1..000000000 --- a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Operation } from '../../../../../../src/http/Operation'; -import { - RegistrationHandler, -} from '../../../../../../src/identity/interaction/email-password/handler/RegistrationHandler'; -import type { - RegistrationManager, RegistrationParams, RegistrationResponse, -} from '../../../../../../src/identity/interaction/email-password/util/RegistrationManager'; -import { readJsonStream } from '../../../../../../src/util/StreamUtil'; -import { createPostJsonOperation } from './Util'; - -describe('A RegistrationHandler', (): void => { - let operation: Operation; - let validated: RegistrationParams; - let details: RegistrationResponse; - let registrationManager: jest.Mocked; - let handler: RegistrationHandler; - - beforeEach(async(): Promise => { - validated = { - email: 'alice@test.email', - password: 'superSecret', - createWebId: true, - register: true, - createPod: true, - rootPod: true, - }; - details = { - email: 'alice@test.email', - createWebId: true, - register: true, - createPod: true, - }; - - registrationManager = { - validateInput: jest.fn().mockReturnValue(validated), - register: jest.fn().mockResolvedValue(details), - } as any; - - handler = new RegistrationHandler(registrationManager); - }); - - it('converts the stream to json and sends it to the registration manager.', async(): Promise => { - const params = { email: 'alice@test.email', password: 'superSecret' }; - operation = createPostJsonOperation(params); - const result = await handler.handle({ operation }); - await expect(readJsonStream(result.data)).resolves.toEqual(details); - expect(result.metadata.contentType).toBe('application/json'); - - expect(registrationManager.validateInput).toHaveBeenCalledTimes(1); - expect(registrationManager.validateInput).toHaveBeenLastCalledWith(params, false); - expect(registrationManager.register).toHaveBeenCalledTimes(1); - expect(registrationManager.register).toHaveBeenLastCalledWith(validated, false); - }); -}); diff --git a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts deleted file mode 100644 index cf589350d..000000000 --- a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Operation } from '../../../../../../src/http/Operation'; -import { - ResetPasswordHandler, -} from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler'; -import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import { readJsonStream } from '../../../../../../src/util/StreamUtil'; -import { createPostJsonOperation } from './Util'; - -describe('A ResetPasswordHandler', (): void => { - let operation: Operation; - const recordId = '123456'; - const url = `/resetURL/${recordId}`; - const email = 'alice@test.email'; - let accountStore: AccountStore; - let handler: ResetPasswordHandler; - - beforeEach(async(): Promise => { - accountStore = { - getForgotPasswordRecord: jest.fn().mockResolvedValue(email), - deleteForgotPasswordRecord: jest.fn(), - changePassword: jest.fn(), - } as any; - - handler = new ResetPasswordHandler(accountStore); - }); - - it('errors for non-string recordIds.', async(): Promise => { - const errorMessage = 'Invalid request. Open the link from your email again'; - operation = createPostJsonOperation({}); - await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); - operation = createPostJsonOperation({ recordId: 5 }); - await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); - }); - - it('errors for invalid passwords.', async(): Promise => { - const errorMessage = 'Your password and confirmation did not match.'; - operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'otherPassword!', recordId }, url); - await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); - }); - - it('errors for invalid emails.', async(): Promise => { - const errorMessage = 'This reset password link is no longer valid.'; - operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url); - (accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined); - await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); - }); - - it('renders a message on success.', async(): Promise => { - operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url); - const result = await handler.handle({ operation }); - await expect(readJsonStream(result.data)).resolves.toEqual({}); - expect(result.metadata.contentType).toBe('application/json'); - expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1); - expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); - expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1); - expect(accountStore.deleteForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); - expect(accountStore.changePassword).toHaveBeenCalledTimes(1); - expect(accountStore.changePassword).toHaveBeenLastCalledWith(email, 'password!'); - }); -}); diff --git a/test/unit/identity/interaction/email-password/handler/Util.ts b/test/unit/identity/interaction/email-password/handler/Util.ts deleted file mode 100644 index 69ecd4880..000000000 --- a/test/unit/identity/interaction/email-password/handler/Util.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Operation } from '../../../../../../src/http/Operation'; -import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation'; - -/** - * Creates a mock HttpRequest which is a stream of an object encoded as application/json - * and a matching content-type header. - * @param data - Object to encode. - * @param url - URL value of the request. - */ -export function createPostJsonOperation(data: NodeJS.Dict, url?: string): Operation { - return { - method: 'POST', - preferences: {}, - target: { path: url ?? 'http://test.com/' }, - body: new BasicRepresentation(JSON.stringify(data), 'application/json'), - }; -} diff --git a/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts b/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts deleted file mode 100644 index 83b8891cc..000000000 --- a/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { AccountSettings } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import type { - EmailPasswordData, -} from '../../../../../../src/identity/interaction/email-password/storage/BaseAccountStore'; -import { BaseAccountStore } from '../../../../../../src/identity/interaction/email-password/storage/BaseAccountStore'; -import type { ExpiringStorage } from '../../../../../../src/storage/keyvalue/ExpiringStorage'; -import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; - -describe('A BaseAccountStore', (): void => { - let storage: KeyValueStorage; - let forgotPasswordStorage: ExpiringStorage; - const saltRounds = 11; - let store: BaseAccountStore; - const email = 'test@test.com'; - const webId = 'http://test.com/#webId'; - const password = 'password!'; - const settings: AccountSettings = { useIdp: true, clientCredentials: []}; - - beforeEach(async(): Promise => { - const map = new Map(); - storage = { - get: jest.fn((id: string): any => map.get(id)), - set: jest.fn((id: string, value: any): any => map.set(id, value)), - delete: jest.fn((id: string): any => map.delete(id)), - } as any; - - forgotPasswordStorage = { - get: jest.fn((id: string): any => map.get(id)), - set: jest.fn((id: string, value: any): any => map.set(id, value)), - delete: jest.fn((id: string): any => map.delete(id)), - } as any; - - store = new BaseAccountStore(storage, forgotPasswordStorage, saltRounds); - }); - - it('can create accounts.', async(): Promise => { - await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); - }); - - it('errors when creating a second account for an email.', async(): Promise => { - await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); - await expect(store.create(email, webId, 'diffPass', settings)).rejects.toThrow('Account already exists'); - }); - - it('errors when creating a second account for a WebID.', async(): Promise => { - await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); - await expect(store.create('bob@test.email', webId, 'diffPass', settings)) - .rejects.toThrow('There already is an account for this WebID'); - }); - - it('errors when authenticating a non-existent account.', async(): Promise => { - await expect(store.authenticate(email, password)).rejects.toThrow('Account does not exist'); - }); - - it('errors when authenticating an unverified account.', async(): Promise => { - await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); - await expect(store.authenticate(email, 'wrongPassword')).rejects.toThrow('Account still needs to be verified'); - }); - - it('errors when verifying a non-existent account.', async(): Promise => { - await expect(store.verify(email)).rejects.toThrow('Account does not exist'); - }); - - it('errors when authenticating with the wrong password.', async(): Promise => { - await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); - await expect(store.verify(email)).resolves.toBeUndefined(); - await expect(store.authenticate(email, 'wrongPassword')).rejects.toThrow('Incorrect password'); - }); - - it('can authenticate.', async(): Promise => { - await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); - await expect(store.verify(email)).resolves.toBeUndefined(); - await expect(store.authenticate(email, password)).resolves.toBe(webId); - }); - - it('errors when changing the password of a non-existent account.', async(): Promise => { - await expect(store.changePassword(email, password)).rejects.toThrow('Account does not exist'); - }); - - it('can change the password.', async(): Promise => { - const newPassword = 'newPassword!'; - await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); - await expect(store.verify(email)).resolves.toBeUndefined(); - await expect(store.changePassword(email, newPassword)).resolves.toBeUndefined(); - await expect(store.authenticate(email, newPassword)).resolves.toBe(webId); - }); - - it('can get the settings.', async(): Promise => { - await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); - await expect(store.verify(email)).resolves.toBeUndefined(); - await expect(store.getSettings(webId)).resolves.toBe(settings); - }); - - it('can update the settings.', async(): Promise => { - const newSettings = { webId, useIdp: false, clientCredentials: []}; - await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); - await expect(store.verify(email)).resolves.toBeUndefined(); - await expect(store.updateSettings(webId, newSettings)).resolves.toBeUndefined(); - await expect(store.getSettings(webId)).resolves.toBe(newSettings); - }); - - it('can delete an account.', async(): Promise => { - await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); - await expect(store.deleteAccount(email)).resolves.toBeUndefined(); - await expect(store.authenticate(email, password)).rejects.toThrow('Account does not exist'); - await expect(store.getSettings(webId)).rejects.toThrow('Account does not exist'); - }); - - it('does nothing when deleting non-existent accounts.', async(): Promise => { - await expect(store.deleteAccount(email)).resolves.toBeUndefined(); - }); - - it('errors when forgetting the password of an account that does not exist.', async(): Promise => { - await expect(store.generateForgotPasswordRecord(email)).rejects.toThrow('Account does not exist'); - }); - - it('generates a recordId when a password was forgotten.', async(): Promise => { - await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); - const recordId = await store.generateForgotPasswordRecord(email); - expect(typeof recordId).toBe('string'); - }); - - it('returns undefined if there is no matching record to retrieve.', async(): Promise => { - await expect(store.getForgotPasswordRecord('unknownRecord')).resolves.toBeUndefined(); - }); - - it('returns the email matching the forgotten password record.', async(): Promise => { - await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); - const recordId = await store.generateForgotPasswordRecord(email); - await expect(store.getForgotPasswordRecord(recordId)).resolves.toBe(email); - }); - - it('can delete stored forgotten password records.', async(): Promise => { - await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); - const recordId = await store.generateForgotPasswordRecord(email); - await expect(store.deleteForgotPasswordRecord(recordId)).resolves.toBeUndefined(); - await expect(store.getForgotPasswordRecord('unknownRecord')).resolves.toBeUndefined(); - }); -}); diff --git a/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts b/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts deleted file mode 100644 index f6befb7de..000000000 --- a/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -import type { ResourceIdentifier } from '../../../../../../src/http/representation/ResourceIdentifier'; -import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import { - RegistrationManager, -} from '../../../../../../src/identity/interaction/email-password/util/RegistrationManager'; -import type { OwnershipValidator } from '../../../../../../src/identity/ownership/OwnershipValidator'; -import type { IdentifierGenerator } from '../../../../../../src/pods/generate/IdentifierGenerator'; -import type { PodManager } from '../../../../../../src/pods/PodManager'; -import type { PodSettings } from '../../../../../../src/pods/settings/PodSettings'; -import { joinUrl } from '../../../../../../src/util/PathUtil'; - -describe('A RegistrationManager', (): void => { - // "Correct" values for easy object creation - const webId = 'http://alice.test.com/card#me'; - const email = 'alice@test.email'; - const password = 'superSecretPassword'; - const confirmPassword = password; - const podName = 'alice'; - const podBaseUrl = 'http://test.com/alice/'; - const createWebId = true; - const register = true; - const createPod = true; - const rootPod = true; - - const baseUrl = 'http://test.com/'; - const webIdSuffix = '/profile/card'; - let podSettings: PodSettings; - let identifierGenerator: IdentifierGenerator; - let ownershipValidator: OwnershipValidator; - let accountStore: AccountStore; - let podManager: PodManager; - let manager: RegistrationManager; - - beforeEach(async(): Promise => { - podSettings = { email, webId, podBaseUrl }; - - identifierGenerator = { - generate: jest.fn((name: string): ResourceIdentifier => ({ path: `${baseUrl}${name}/` })), - extractPod: jest.fn(), - }; - - ownershipValidator = { - handleSafe: jest.fn(), - } as any; - - accountStore = { - create: jest.fn(), - verify: jest.fn(), - deleteAccount: jest.fn(), - } as any; - - podManager = { - createPod: jest.fn(), - }; - - manager = new RegistrationManager({ - baseUrl, - webIdSuffix, - identifierGenerator, - accountStore, - ownershipValidator, - podManager, - }); - }); - - describe('validating data', (): void => { - it('errors on invalid emails.', async(): Promise => { - let input: any = { email: undefined }; - expect((): any => manager.validateInput(input, false)).toThrow('Please enter a valid e-mail address.'); - - input = { email: '' }; - expect((): any => manager.validateInput(input, false)).toThrow('Please enter a valid e-mail address.'); - - input = { email: 'invalidEmail' }; - expect((): any => manager.validateInput(input, false)).toThrow('Please enter a valid e-mail address.'); - }); - - it('errors on invalid passwords.', async(): Promise => { - const input: any = { email, webId, password, confirmPassword: 'bad' }; - expect((): any => manager.validateInput(input, false)).toThrow('Your password and confirmation did not match.'); - }); - - it('errors on missing passwords.', async(): Promise => { - const input: any = { email, webId }; - expect((): any => manager.validateInput(input, false)).toThrow('Please enter a password.'); - }); - - it('errors when setting rootPod to true when not allowed.', async(): Promise => { - const input = { email, password, confirmPassword, createWebId, rootPod }; - expect((): any => manager.validateInput(input, false)).toThrow('Creating a root pod is not supported.'); - }); - - it('errors when a required WebID is not valid.', async(): Promise => { - let input: any = { email, password, confirmPassword, register, webId: undefined }; - expect((): any => manager.validateInput(input, false)).toThrow('Please enter a valid WebID.'); - - input = { email, password, confirmPassword, register, webId: '' }; - expect((): any => manager.validateInput(input, false)).toThrow('Please enter a valid WebID.'); - }); - - it('errors on invalid pod names when required.', async(): Promise => { - let input: any = { email, webId, password, confirmPassword, createPod, podName: undefined }; - expect((): any => manager.validateInput(input, false)).toThrow('Please specify a Pod name.'); - - input = { email, webId, password, confirmPassword, createPod, podName: ' ' }; - expect((): any => manager.validateInput(input, false)).toThrow('Please specify a Pod name.'); - - input = { email, webId, password, confirmPassword, createWebId }; - expect((): any => manager.validateInput(input, false)).toThrow('Please specify a Pod name.'); - }); - - it('errors when no option is chosen.', async(): Promise => { - const input = { email, webId, password, confirmPassword }; - expect((): any => manager.validateInput(input, false)).toThrow('Please register for a WebID or create a Pod.'); - }); - - it('adds the template parameter if there is one.', async(): Promise => { - const input = { email, webId, password, confirmPassword, podName, template: 'template', createPod }; - expect(manager.validateInput(input, false)).toEqual({ - email, - webId, - password, - podName, - template: 'template', - createWebId: false, - register: false, - createPod, - rootPod: false, - }); - }); - - it('does not require a pod name when creating a root pod.', async(): Promise => { - const input = { email, password, confirmPassword, webId, createPod, rootPod }; - expect(manager.validateInput(input, true)).toEqual({ - email, password, webId, createWebId: false, register: false, createPod, rootPod, - }); - }); - - it('trims non-password input parameters.', async(): Promise => { - let input: any = { - email: ` ${email} `, - password: ' a ', - confirmPassword: ' a ', - podName: ` ${podName} `, - template: ' template ', - createWebId, - register, - createPod, - }; - expect(manager.validateInput(input, false)).toEqual({ - email, password: ' a ', podName, template: 'template', createWebId, register, createPod, rootPod: false, - }); - - input = { email, webId: ` ${webId} `, password: ' a ', confirmPassword: ' a ', register: true }; - expect(manager.validateInput(input, false)).toEqual({ - email, webId, password: ' a ', createWebId: false, register, createPod: false, rootPod: false, - }); - }); - }); - - describe('handling data', (): void => { - it('can register a user.', async(): Promise => { - const params: any = { email, webId, password, register, createPod: false, createWebId: false }; - await expect(manager.register(params, false)).resolves.toEqual({ - email, - webId, - oidcIssuer: baseUrl, - createWebId: false, - register: true, - createPod: false, - }); - - expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); - expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); - expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create) - .toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, clientCredentials: []}); - expect(accountStore.verify).toHaveBeenCalledTimes(1); - expect(accountStore.verify).toHaveBeenLastCalledWith(email); - - expect(identifierGenerator.generate).toHaveBeenCalledTimes(0); - expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); - expect(podManager.createPod).toHaveBeenCalledTimes(0); - }); - - it('can create a pod.', async(): Promise => { - const params: any = { email, webId, password, podName, createPod, createWebId: false, register: false }; - await expect(manager.register(params, false)).resolves.toEqual({ - email, - webId, - oidcIssuer: baseUrl, - podBaseUrl: `${baseUrl}${podName}/`, - createWebId: false, - register: false, - createPod: true, - }); - - expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); - expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); - expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); - expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); - expect(podManager.createPod).toHaveBeenCalledTimes(1); - expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false); - expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create) - .toHaveBeenLastCalledWith(email, webId, password, { useIdp: false, podBaseUrl, clientCredentials: []}); - expect(accountStore.verify).toHaveBeenCalledTimes(1); - - expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); - }); - - it('adds an oidcIssuer to the data when doing both IDP registration and pod creation.', async(): Promise => { - const params: any = { email, webId, password, confirmPassword, podName, register, createPod, createWebId: false }; - podSettings.oidcIssuer = baseUrl; - await expect(manager.register(params, false)).resolves.toEqual({ - email, - webId, - oidcIssuer: baseUrl, - podBaseUrl: `${baseUrl}${podName}/`, - createWebId: false, - register: true, - createPod: true, - }); - - expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); - expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); - expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create) - .toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, podBaseUrl, clientCredentials: []}); - expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); - expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); - expect(podManager.createPod).toHaveBeenCalledTimes(1); - expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false); - expect(accountStore.verify).toHaveBeenCalledTimes(1); - expect(accountStore.verify).toHaveBeenLastCalledWith(email); - - expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); - }); - - it('deletes the created account if pod generation fails.', async(): Promise => { - const params: any = { email, webId, password, confirmPassword, podName, register, createPod }; - podSettings.oidcIssuer = baseUrl; - (podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error')); - await expect(manager.register(params, false)).rejects.toThrow('pod error'); - - expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); - expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); - expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create) - .toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, podBaseUrl, clientCredentials: []}); - expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); - expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); - expect(podManager.createPod).toHaveBeenCalledTimes(1); - expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false); - expect(accountStore.deleteAccount).toHaveBeenCalledTimes(1); - expect(accountStore.deleteAccount).toHaveBeenLastCalledWith(email); - - expect(accountStore.verify).toHaveBeenCalledTimes(0); - }); - - it('can create a WebID with an account and pod.', async(): Promise => { - const params: any = { email, password, confirmPassword, podName, createWebId, register, createPod }; - const generatedWebID = joinUrl(baseUrl, podName, webIdSuffix); - podSettings.webId = generatedWebID; - podSettings.oidcIssuer = baseUrl; - - await expect(manager.register(params, false)).resolves.toEqual({ - email, - webId: generatedWebID, - oidcIssuer: baseUrl, - podBaseUrl: `${baseUrl}${podName}/`, - createWebId: true, - register: true, - createPod: true, - }); - - expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); - expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); - expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, - generatedWebID, - password, - { useIdp: true, podBaseUrl, clientCredentials: []}); - expect(accountStore.verify).toHaveBeenCalledTimes(1); - expect(accountStore.verify).toHaveBeenLastCalledWith(email); - expect(podManager.createPod).toHaveBeenCalledTimes(1); - expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false); - - expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(0); - expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); - }); - - it('can create a root pod.', async(): Promise => { - const params: any = { email, webId, password, createPod, rootPod, createWebId: false, register: false }; - podSettings.podBaseUrl = baseUrl; - await expect(manager.register(params, true)).resolves.toEqual({ - email, - webId, - oidcIssuer: baseUrl, - podBaseUrl: baseUrl, - createWebId: false, - register: false, - createPod: true, - }); - - expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); - expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); - expect(podManager.createPod).toHaveBeenCalledTimes(1); - expect(podManager.createPod).toHaveBeenLastCalledWith({ path: baseUrl }, podSettings, true); - expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, - webId, - password, - { useIdp: false, podBaseUrl: baseUrl, clientCredentials: []}); - expect(accountStore.verify).toHaveBeenCalledTimes(1); - - expect(identifierGenerator.generate).toHaveBeenCalledTimes(0); - expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); - }); - }); -}); diff --git a/test/unit/identity/interaction/login/LogoutHandler.test.ts b/test/unit/identity/interaction/login/LogoutHandler.test.ts new file mode 100644 index 000000000..cb56ed053 --- /dev/null +++ b/test/unit/identity/interaction/login/LogoutHandler.test.ts @@ -0,0 +1,46 @@ +import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; +import type { CookieStore } from '../../../../../src/identity/interaction/account/util/CookieStore'; +import { LogoutHandler } from '../../../../../src/identity/interaction/login/LogoutHandler'; +import { SOLID_HTTP } from '../../../../../src/util/Vocabularies'; + +describe('A LogoutHandler', (): void => { + const accountId = 'accountId'; + const cookie = 'cookie'; + let metadata: RepresentationMetadata; + let cookieStore: jest.Mocked; + let handler: LogoutHandler; + + beforeEach(async(): Promise => { + metadata = new RepresentationMetadata({ [SOLID_HTTP.accountCookie]: cookie }); + + cookieStore = { + get: jest.fn().mockResolvedValue(accountId), + delete: jest.fn(), + } as any; + + handler = new LogoutHandler(cookieStore); + }); + + it('removes the cookie and sets the relevant metadata.', async(): Promise => { + const { json, metadata: outputMetadata } = await handler.handle({ metadata, accountId } as any); + expect(json).toEqual({}); + expect(outputMetadata?.get(SOLID_HTTP.terms.accountCookie)?.value).toBe(cookie); + const date = outputMetadata?.get(SOLID_HTTP.terms.accountCookieExpiration); + expect(date).toBeDefined(); + expect(new Date(date!.value) < new Date()).toBe(true); + expect(cookieStore.delete).toHaveBeenCalledTimes(1); + expect(cookieStore.delete).toHaveBeenLastCalledWith(cookie); + }); + + it('does nothing if the request is not logged in.', async(): Promise => { + metadata = new RepresentationMetadata(); + await expect(handler.handle({ metadata } as any)).resolves.toEqual({ json: {}}); + expect(cookieStore.delete).toHaveBeenCalledTimes(0); + }); + + it('errors if the cookie does not belong to the authenticated account.', async(): Promise => { + cookieStore.get.mockResolvedValueOnce('other-id'); + await expect(handler.handle({ metadata, accountId } as any)).rejects.toThrow('Invalid cookie'); + expect(cookieStore.delete).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/identity/interaction/login/ResolveLoginHandler.test.ts b/test/unit/identity/interaction/login/ResolveLoginHandler.test.ts new file mode 100644 index 000000000..9d97fef57 --- /dev/null +++ b/test/unit/identity/interaction/login/ResolveLoginHandler.test.ts @@ -0,0 +1,169 @@ +import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; +import type { AccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute'; +import { ACCOUNT_SETTINGS_REMEMBER_LOGIN } from '../../../../../src/identity/interaction/account/util/Account'; +import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import type { CookieStore } from '../../../../../src/identity/interaction/account/util/CookieStore'; +import type { JsonRepresentation } from '../../../../../src/identity/interaction/InteractionUtil'; +import type { JsonInteractionHandlerInput } from '../../../../../src/identity/interaction/JsonInteractionHandler'; +import type { LoginOutputType } from '../../../../../src/identity/interaction/login/ResolveLoginHandler'; +import { + ResolveLoginHandler, +} from '../../../../../src/identity/interaction/login/ResolveLoginHandler'; +import { InternalServerError } from '../../../../../src/util/errors/InternalServerError'; +import { CONTENT_TYPE, CONTENT_TYPE_TERM, SOLID_HTTP } from '../../../../../src/util/Vocabularies'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +const accountId = 'accountId'; +let output: JsonRepresentation; +class DummyLoginHandler extends ResolveLoginHandler { + public constructor(accountStore: AccountStore, cookieStore: CookieStore, accountRoute: AccountIdRoute) { + super(accountStore, cookieStore, accountRoute); + } + + public async login(): Promise> { + return output; + } +} + +describe('A ResolveLoginHandler', (): void => { + const cookie = 'cookie'; + let metadata: RepresentationMetadata; + let input: JsonInteractionHandlerInput; + let account: Account; + let accountStore: jest.Mocked; + let cookieStore: jest.Mocked; + let accountRoute: jest.Mocked; + let handler: DummyLoginHandler; + + beforeEach(async(): Promise => { + input = { + json: {}, + metadata: new RepresentationMetadata(), + target: { path: 'http://example.com/' }, + method: 'POST', + }; + + metadata = new RepresentationMetadata({ [CONTENT_TYPE]: 'text/turtle' }); + output = { + json: { accountId, data: 'data' } as LoginOutputType, + metadata, + }; + + account = createAccount(); + accountStore = mockAccountStore(account); + + cookieStore = { + generate: jest.fn().mockResolvedValue(cookie), + delete: jest.fn(), + } as any; + + accountRoute = { + getPath: jest.fn().mockReturnValue('http://example.com/foo'), + matchPath: jest.fn().mockReturnValue(true), + }; + + handler = new DummyLoginHandler(accountStore, cookieStore, accountRoute); + }); + + it('removes the ID from the output and adds a cookie.', async(): Promise => { + await expect(handler.handle(input)).resolves.toEqual({ json: { + data: 'data', + cookie, + resource: 'http://example.com/foo', + }, + metadata }); + expect(metadata.get(SOLID_HTTP.terms.accountCookie)?.value).toBe(cookie); + + expect(cookieStore.generate).toHaveBeenCalledTimes(1); + expect(cookieStore.generate).toHaveBeenLastCalledWith(accountId); + expect(cookieStore.delete).toHaveBeenCalledTimes(0); + expect(accountStore.get).toHaveBeenCalledTimes(0); + }); + + it('generates a metadata object if the login handler did not provide one.', async(): Promise => { + output = { json: { accountId, data: 'data' } as LoginOutputType }; + const result = await handler.handle(input); + expect(result).toEqual({ json: { + data: 'data', + cookie, + resource: 'http://example.com/foo', + }, + metadata: expect.any(RepresentationMetadata) }); + expect(result.metadata).not.toBe(metadata); + expect(result.metadata?.get(CONTENT_TYPE_TERM)).toBeUndefined(); + expect(accountStore.get).toHaveBeenCalledTimes(0); + }); + + it('adds a location field if there is an OIDC interaction.', async(): Promise => { + input.oidcInteraction = { + lastSubmission: { login: { accountId: 'id' }}, + persist: jest.fn(), + returnTo: 'returnTo', + } as any; + await expect(handler.handle(input)).resolves.toEqual({ json: { + data: 'data', + cookie, + resource: 'http://example.com/foo', + location: 'returnTo', + }, + metadata }); + + expect(input.oidcInteraction!.persist).toHaveBeenCalledTimes(1); + expect(input.oidcInteraction!.result).toEqual({ + login: { accountId: 'id' }, + }); + expect(accountStore.get).toHaveBeenCalledTimes(0); + }); + + it('updates the account remember settings if necessary.', async(): Promise => { + output = { + json: { ...output.json, remember: true }, + metadata, + }; + await expect(handler.handle(input)).resolves.toEqual({ json: { + data: 'data', + cookie, + resource: 'http://example.com/foo', + }, + metadata }); + + expect(cookieStore.generate).toHaveBeenCalledTimes(1); + expect(cookieStore.generate).toHaveBeenLastCalledWith(accountId); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(accountId); + expect(accountStore.update).toHaveBeenCalledTimes(1); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(account.settings[ACCOUNT_SETTINGS_REMEMBER_LOGIN]).toBe(true); + }); + + it('errors if the account can not be found.', async(): Promise => { + output = { + json: { ...output.json, remember: true }, + metadata, + }; + accountStore.get.mockResolvedValue(undefined); + await expect(handler.handle(input)).rejects.toThrow(InternalServerError); + + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(accountId); + expect(accountStore.update).toHaveBeenCalledTimes(0); + }); + + it('deletes the old cookie if there was one in the input.', async(): Promise => { + input.metadata.set(SOLID_HTTP.terms.accountCookie, 'old-cookie-value'); + await expect(handler.handle(input)).resolves.toEqual({ json: { + data: 'data', + cookie, + resource: 'http://example.com/foo', + }, + metadata }); + expect(metadata.get(SOLID_HTTP.terms.accountCookie)?.value).toBe(cookie); + + expect(cookieStore.generate).toHaveBeenCalledTimes(1); + expect(cookieStore.generate).toHaveBeenLastCalledWith(accountId); + expect(cookieStore.delete).toHaveBeenCalledTimes(1); + expect(cookieStore.delete).toHaveBeenLastCalledWith('old-cookie-value'); + expect(accountStore.get).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/identity/interaction/oidc/CancelOidcHandler.test.ts b/test/unit/identity/interaction/oidc/CancelOidcHandler.test.ts new file mode 100644 index 000000000..a9dff2f03 --- /dev/null +++ b/test/unit/identity/interaction/oidc/CancelOidcHandler.test.ts @@ -0,0 +1,33 @@ +import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler'; +import { CancelOidcHandler } from '../../../../../src/identity/interaction/oidc/CancelOidcHandler'; + +describe('A CancelOidcHandler', (): void => { + let oidcInteraction: Interaction; + let handler: CancelOidcHandler; + + beforeEach(async(): Promise => { + oidcInteraction = { + lastSubmission: { login: { accountId: 'id' }}, + persist: jest.fn(), + session: { + cookie: 'cookie', + }, + returnTo: 'returnTo', + } as any; + + handler = new CancelOidcHandler(); + }); + + it('finishes the interaction with an error.', async(): Promise => { + await expect(handler.handle({ oidcInteraction } as any)).rejects.toThrow(expect.objectContaining({ + statusCode: 302, + location: 'returnTo', + })); + expect(oidcInteraction.persist).toHaveBeenCalledTimes(1); + expect(oidcInteraction.result).toEqual({ + error: 'access_denied', + // eslint-disable-next-line @typescript-eslint/naming-convention + error_description: 'User cancelled the interaction.', + }); + }); +}); diff --git a/test/unit/identity/interaction/oidc/ClientInfoHandler.test.ts b/test/unit/identity/interaction/oidc/ClientInfoHandler.test.ts new file mode 100644 index 000000000..646f7fc5a --- /dev/null +++ b/test/unit/identity/interaction/oidc/ClientInfoHandler.test.ts @@ -0,0 +1,62 @@ +import type { ProviderFactory } from '../../../../../src/identity/configuration/ProviderFactory'; +import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler'; +import { ClientInfoHandler } from '../../../../../src/identity/interaction/oidc/ClientInfoHandler'; +import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; +import type Provider from '../../../../../templates/types/oidc-provider'; + +/* eslint-disable @typescript-eslint/naming-convention */ +describe('A ClientInfoHandler', (): void => { + let oidcInteraction: Interaction; + const clientMetadata = { + client_id: 'clientId', + client_name: 'clientName', + unknownField: 'super-secret', + }; + let provider: jest.Mocked; + let providerFactory: jest.Mocked; + let handler: ClientInfoHandler; + + beforeEach(async(): Promise => { + oidcInteraction = { + params: { client_id: 'clientId' }, + session: { accountId: 'http://example.com/card#me' }, + } as any; + + provider = { + Client: { + find: (id: string): any => (id ? { metadata: jest.fn().mockReturnValue(clientMetadata) } : undefined), + }, + } as any; + + providerFactory = { + getProvider: jest.fn().mockResolvedValue(provider), + }; + + handler = new ClientInfoHandler(providerFactory); + }); + + it('returns the known client metadata fields.', async(): Promise => { + await expect(handler.handle({ oidcInteraction } as any)).resolves.toEqual({ json: { + client: { + '@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld', + client_id: 'clientId', + client_name: 'clientName', + }, + webId: 'http://example.com/card#me', + }}); + }); + + it('returns empty info if there is none.', async(): Promise => { + delete oidcInteraction.params.client_id; + await expect(handler.handle({ oidcInteraction } as any)).resolves.toEqual({ json: { + client: { + '@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld', + }, + webId: 'http://example.com/card#me', + }}); + }); + + it('errors if there is no OIDC interaction.', async(): Promise => { + await expect(handler.handle({} as any)).rejects.toThrow(BadRequestHttpError); + }); +}); diff --git a/test/unit/identity/interaction/ConsentHandler.test.ts b/test/unit/identity/interaction/oidc/ConsentHandler.test.ts similarity index 51% rename from test/unit/identity/interaction/ConsentHandler.test.ts rename to test/unit/identity/interaction/oidc/ConsentHandler.test.ts index f7c732ab1..12ecb1f2d 100644 --- a/test/unit/identity/interaction/ConsentHandler.test.ts +++ b/test/unit/identity/interaction/oidc/ConsentHandler.test.ts @@ -1,11 +1,9 @@ -import type { ProviderFactory } from '../../../../src/identity/configuration/ProviderFactory'; -import { ConsentHandler } from '../../../../src/identity/interaction/ConsentHandler'; -import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler'; -import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError'; -import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -import { readJsonStream } from '../../../../src/util/StreamUtil'; -import type Provider from '../../../../templates/types/oidc-provider'; -import { createPostJsonOperation } from './email-password/handler/Util'; +import type { ProviderFactory } from '../../../../../src/identity/configuration/ProviderFactory'; +import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler'; +import { ConsentHandler } from '../../../../../src/identity/interaction/oidc/ConsentHandler'; +import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError'; +import { NotImplementedHttpError } from '../../../../../src/util/errors/NotImplementedHttpError'; +import type Provider from '../../../../../templates/types/oidc-provider'; const newGrantId = 'newGrantId'; class DummyGrant { @@ -46,10 +44,6 @@ class DummyGrant { describe('A ConsentHandler', (): void => { const accountId = 'http://example.com/id#me'; const clientId = 'clientId'; - const clientMetadata = { - // eslint-disable-next-line @typescript-eslint/naming-convention - client_id: 'clientId', - }; let grantFn: jest.Mock & { find: jest.Mock }; let knownGrant: DummyGrant; let oidcInteraction: Interaction; @@ -61,12 +55,12 @@ describe('A ConsentHandler', (): void => { oidcInteraction = { session: { accountId, - save: jest.fn(), + persist: jest.fn(), }, // eslint-disable-next-line @typescript-eslint/naming-convention params: { client_id: clientId }, prompt: { details: {}}, - save: jest.fn(), + persist: jest.fn(), } as any; knownGrant = new DummyGrant({ accountId, clientId }); @@ -76,11 +70,8 @@ describe('A ConsentHandler', (): void => { provider = { /* eslint-disable @typescript-eslint/naming-convention */ Grant: grantFn, - Client: { - find: (id: string): any => (id ? { metadata: jest.fn().mockReturnValue(clientMetadata) } : undefined), - }, Session: { - find: (): Interaction['session'] => oidcInteraction.session, + find: (): unknown => oidcInteraction.session, }, /* eslint-enable @typescript-eslint/naming-convention */ } as any; @@ -92,51 +83,23 @@ describe('A ConsentHandler', (): void => { handler = new ConsentHandler(providerFactory); }); - it('errors if no oidcInteraction is defined on POST requests.', async(): Promise => { + it('errors if no oidcInteraction is defined.', async(): Promise => { const error = expect.objectContaining({ statusCode: 400, message: 'This action can only be performed as part of an OIDC authentication flow.', errorCode: 'E0002', }); - await expect(handler.canHandle({ operation: createPostJsonOperation({}) })).rejects.toThrow(error); - - await expect(handler.canHandle({ operation: createPostJsonOperation({}), oidcInteraction })) - .resolves.toBeUndefined(); - }); - - it('returns the client metadata on a GET request.', async(): Promise => { - const operation = { method: 'GET', target: { path: 'http://example.com/foo' }} as any; - const representation = await handler.handle({ operation, oidcInteraction }); - await expect(readJsonStream(representation.data)).resolves.toEqual({ - client: { - ...clientMetadata, - '@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld', - }, - webId: accountId, - }); - }); - - it('returns an empty object if no client was found.', async(): Promise => { - delete oidcInteraction.params.client_id; - const operation = { method: 'GET', target: { path: 'http://example.com/foo' }} as any; - const representation = await handler.handle({ operation, oidcInteraction }); - await expect(readJsonStream(representation.data)).resolves.toEqual({ - client: { - '@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld', - }, - webId: accountId, - }); + await expect(handler.handle({ json: {}} as any)).rejects.toThrow(error); }); it('requires an oidcInteraction with a defined session.', async(): Promise => { oidcInteraction.session = undefined; - await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction })) + await expect(handler.handle({ json: {}, oidcInteraction } as any)) .rejects.toThrow(NotImplementedHttpError); }); it('throws a redirect error.', async(): Promise => { - const operation = createPostJsonOperation({}); - await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + await expect(handler.handle({ json: {}, oidcInteraction } as any)).rejects.toThrow(FoundHttpError); }); it('stores the requested scopes and claims in the grant.', async(): Promise => { @@ -146,8 +109,7 @@ describe('A ConsentHandler', (): void => { missingResourceScopes: { resource: [ 'scope1', 'scope2' ]}, }; - const operation = createPostJsonOperation({ remember: true }); - await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + await expect(handler.handle({ json: { remember: true }, oidcInteraction } as any)).rejects.toThrow(FoundHttpError); expect(grantFn.mock.results).toHaveLength(1); expect(grantFn.mock.results[0].value.scopes).toEqual([ 'scope1 scope2' ]); expect(grantFn.mock.results[0].value.claims).toEqual([ 'claim1', 'claim2' ]); @@ -156,33 +118,23 @@ describe('A ConsentHandler', (): void => { }); it('creates a new Grant when needed.', async(): Promise => { - const operation = createPostJsonOperation({}); - await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + await expect(handler.handle({ json: {}, oidcInteraction } as any)).rejects.toThrow(FoundHttpError); expect(grantFn).toHaveBeenCalledTimes(1); expect(grantFn).toHaveBeenLastCalledWith({ accountId, clientId }); expect(grantFn.find).toHaveBeenCalledTimes(0); }); it('reuses existing Grant objects.', async(): Promise => { - const operation = createPostJsonOperation({}); oidcInteraction.grantId = '123456'; - await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + await expect(handler.handle({ json: {}, oidcInteraction } as any)).rejects.toThrow(FoundHttpError); expect(grantFn).toHaveBeenCalledTimes(0); expect(grantFn.find).toHaveBeenCalledTimes(1); expect(grantFn.find).toHaveBeenLastCalledWith('123456'); }); - it('rejectes offline_access as scope if a user does not want to be remembered.', async(): Promise => { - const operation = createPostJsonOperation({}); - await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); + it('rejects offline_access as scope if a user does not want to be remembered.', async(): Promise => { + await expect(handler.handle({ json: {}, oidcInteraction } as any)).rejects.toThrow(FoundHttpError); expect(grantFn.mock.results).toHaveLength(1); expect(grantFn.mock.results[0].value.rejectedScopes).toEqual([ 'offline_access' ]); }); - - it('deletes the accountId when logout is provided.', async(): Promise => { - const operation = createPostJsonOperation({ logOut: true }); - await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); - expect((oidcInteraction!.session! as any).save).toHaveBeenCalledTimes(1); - expect(oidcInteraction!.session!.accountId).toBeUndefined(); - }); }); diff --git a/test/unit/identity/interaction/oidc/ForgetWebIdHandler.test.ts b/test/unit/identity/interaction/oidc/ForgetWebIdHandler.test.ts new file mode 100644 index 000000000..27de27825 --- /dev/null +++ b/test/unit/identity/interaction/oidc/ForgetWebIdHandler.test.ts @@ -0,0 +1,43 @@ +import type { ProviderFactory } from '../../../../../src/identity/configuration/ProviderFactory'; +import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler'; +import { ForgetWebIdHandler } from '../../../../../src/identity/interaction/oidc/ForgetWebIdHandler'; +import type Provider from '../../../../../templates/types/oidc-provider'; + +describe('A ForgetWebIdHandler', (): void => { + let oidcInteraction: Interaction; + let provider: jest.Mocked; + let providerFactory: jest.Mocked; + let handler: ForgetWebIdHandler; + + beforeEach(async(): Promise => { + oidcInteraction = { + lastSubmission: { login: { accountId: 'id' }}, + persist: jest.fn(), + session: { + cookie: 'cookie', + }, + returnTo: 'returnTo', + } as any; + + provider = { + /* eslint-disable @typescript-eslint/naming-convention */ + Session: { + find: jest.fn().mockResolvedValue({ persist: jest.fn() }), + }, + /* eslint-enable @typescript-eslint/naming-convention */ + } as any; + + providerFactory = { + getProvider: jest.fn().mockResolvedValue(provider), + }; + + handler = new ForgetWebIdHandler(providerFactory); + }); + + it('forgets the WebID and updates the interaction.', async(): Promise => { + await expect(handler.handle({ oidcInteraction } as any)).rejects.toThrow(expect.objectContaining({ + statusCode: 302, + location: 'returnTo', + })); + }); +}); diff --git a/test/unit/identity/interaction/oidc/PickWebIdHandler.test.ts b/test/unit/identity/interaction/oidc/PickWebIdHandler.test.ts new file mode 100644 index 000000000..d71621593 --- /dev/null +++ b/test/unit/identity/interaction/oidc/PickWebIdHandler.test.ts @@ -0,0 +1,103 @@ +import type { ProviderFactory } from '../../../../../src/identity/configuration/ProviderFactory'; +import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler'; +import { PickWebIdHandler } from '../../../../../src/identity/interaction/oidc/PickWebIdHandler'; +import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; +import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError'; +import type Provider from '../../../../../templates/types/oidc-provider'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +describe('A PickWebIdHandler', (): void => { + const accountId = 'accountId'; + const webId1 = 'http://example.com/.account/card1#me'; + const webId2 = 'http://example.com/.account/card2#me'; + let json: unknown; + let oidcInteraction: Interaction; + let account: Account; + let accountStore: jest.Mocked; + let provider: jest.Mocked; + let providerFactory: jest.Mocked; + let picker: PickWebIdHandler; + + beforeEach(async(): Promise => { + oidcInteraction = { + lastSubmission: { login: { accountId: 'id' }}, + persist: jest.fn(), + session: { + cookie: 'cookie', + }, + returnTo: 'returnTo', + } as any; + + json = { + webId: webId1, + }; + + account = createAccount(accountId); + account.webIds[webId1] = 'resource'; + account.webIds[webId2] = 'resource'; + + accountStore = mockAccountStore(account); + + provider = { + /* eslint-disable @typescript-eslint/naming-convention */ + Session: { + find: jest.fn().mockResolvedValue({ persist: jest.fn() }), + }, + /* eslint-enable @typescript-eslint/naming-convention */ + } as any; + + providerFactory = { + getProvider: jest.fn().mockResolvedValue(provider), + }; + + picker = new PickWebIdHandler(accountStore, providerFactory); + }); + + it('requires a WebID as input and returns the available WebIDs.', async(): Promise => { + await expect(picker.getView({ accountId } as any)).resolves.toEqual({ + json: { + fields: { + webId: { + required: true, + type: 'string', + }, + remember: { + required: false, + type: 'boolean', + }, + }, + webIds: [ + webId1, + webId2, + ], + }, + }); + }); + + it('allows users to pick a WebID.', async(): Promise => { + const result = picker.handle({ oidcInteraction, accountId, json } as any); + await expect(result).rejects.toThrow(FoundHttpError); + await expect(result).rejects.toEqual(expect.objectContaining({ location: oidcInteraction.returnTo })); + + expect((await (provider.Session.find as jest.Mock).mock.results[0].value).persist).toHaveBeenCalledTimes(1); + expect(oidcInteraction.persist).toHaveBeenCalledTimes(1); + expect(oidcInteraction.result).toEqual({ + login: { + accountId: webId1, + remember: false, + }, + }); + }); + + it('errors if there is no OIDC interaction.', async(): Promise => { + await expect(picker.handle({ accountId, json } as any)).rejects.toThrow(BadRequestHttpError); + }); + + it('errors if the WebID is not part of the account.', async(): Promise => { + json = { webId: 'http://example.com/somewhere/else#me' }; + await expect(picker.handle({ oidcInteraction, accountId, json } as any)) + .rejects.toThrow('WebID does not belong to this account.'); + }); +}); diff --git a/test/unit/identity/interaction/oidc/PromptHandler.test.ts b/test/unit/identity/interaction/oidc/PromptHandler.test.ts new file mode 100644 index 000000000..b94484e2b --- /dev/null +++ b/test/unit/identity/interaction/oidc/PromptHandler.test.ts @@ -0,0 +1,33 @@ +import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler'; +import { PromptHandler } from '../../../../../src/identity/interaction/oidc/PromptHandler'; +import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute'; +import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; + +describe('A PromptHandler', (): void => { + let oidcInteraction: Interaction; + let promptRoutes: Record>; + let handler: PromptHandler; + + beforeEach(async(): Promise => { + oidcInteraction = { prompt: { name: 'login' }} as any; + promptRoutes = { + login: { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any, + }; + handler = new PromptHandler(promptRoutes); + }); + + it('errors if there is no interaction.', async(): Promise => { + await expect(handler.handle({ } as any)).rejects.toThrow(BadRequestHttpError); + }); + + it('errors if the prompt is unsupported.', async(): Promise => { + oidcInteraction.prompt.name = 'unsupported'; + await expect(handler.handle({ oidcInteraction } as any)).rejects.toThrow(BadRequestHttpError); + }); + + it('returns a JSON body with the location and prompt.', async(): Promise => { + await expect(handler.handle({ oidcInteraction } as any)).resolves.toEqual( + { json: { prompt: 'login', location: 'http://example.com/idp/login/' }}, + ); + }); +}); diff --git a/test/unit/identity/interaction/password/CreatePasswordHandler.test.ts b/test/unit/identity/interaction/password/CreatePasswordHandler.test.ts new file mode 100644 index 000000000..9f6846793 --- /dev/null +++ b/test/unit/identity/interaction/password/CreatePasswordHandler.test.ts @@ -0,0 +1,91 @@ +import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { CreatePasswordHandler } from '../../../../../src/identity/interaction/password/CreatePasswordHandler'; +import type { PasswordIdRoute } from '../../../../../src/identity/interaction/password/util/PasswordIdRoute'; +import { PASSWORD_METHOD } from '../../../../../src/identity/interaction/password/util/PasswordStore'; +import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +describe('A CreatePasswordHandler', (): void => { + const email = 'example@example.com'; + const password = 'supersecret!'; + const resource = 'http://example.com/foo'; + let account: Account; + let json: unknown; + let passwordStore: jest.Mocked; + let accountStore: jest.Mocked; + let passwordRoute: PasswordIdRoute; + let handler: CreatePasswordHandler; + + beforeEach(async(): Promise => { + json = { email, password }; + + passwordStore = { + create: jest.fn(), + confirmVerification: jest.fn(), + delete: jest.fn(), + } as any; + + account = createAccount(); + accountStore = mockAccountStore(account); + + passwordRoute = { + getPath: jest.fn().mockReturnValue(resource), + matchPath: jest.fn().mockReturnValue(true), + }; + + handler = new CreatePasswordHandler(passwordStore, accountStore, passwordRoute); + }); + + it('requires specific input fields.', async(): Promise => { + await expect(handler.getView()).resolves.toEqual({ + json: { + fields: { + email: { + required: true, + type: 'string', + }, + password: { + required: true, + type: 'string', + }, + }, + }, + }); + }); + + it('returns the resource URL of the created login.', async(): Promise => { + await expect(handler.handle({ accountId: account.id, json } as any)).resolves.toEqual({ json: { resource }}); + expect(passwordStore.create).toHaveBeenCalledTimes(1); + expect(passwordStore.create).toHaveBeenLastCalledWith(email, account.id, password); + expect(passwordStore.confirmVerification).toHaveBeenCalledTimes(1); + expect(passwordStore.confirmVerification).toHaveBeenLastCalledWith(email); + expect(accountStore.update).toHaveBeenCalledTimes(1); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(account.logins[PASSWORD_METHOD]?.[email]).toBe(resource); + expect(passwordStore.delete).toHaveBeenCalledTimes(0); + }); + + it('throws an error if the account already has a login with this email address.', async(): Promise => { + await handler.handle({ accountId: account.id, json } as any); + jest.clearAllMocks(); + await expect(handler.handle({ accountId: account.id, json } as any)) + .rejects.toThrow('This account already has a login method for this e-mail address.'); + expect(passwordStore.create).toHaveBeenCalledTimes(0); + expect(accountStore.update).toHaveBeenCalledTimes(0); + }); + + it('reverts changes if there is an error writing the data.', async(): Promise => { + const error = new Error('bad data'); + accountStore.update.mockRejectedValueOnce(error); + await expect(handler.handle({ accountId: account.id, json } as any)).rejects.toThrow(error); + expect(passwordStore.create).toHaveBeenCalledTimes(1); + expect(passwordStore.create).toHaveBeenLastCalledWith(email, account.id, password); + expect(passwordStore.confirmVerification).toHaveBeenCalledTimes(1); + expect(passwordStore.confirmVerification).toHaveBeenLastCalledWith(email); + expect(accountStore.update).toHaveBeenCalledTimes(1); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(passwordStore.delete).toHaveBeenCalledTimes(1); + expect(passwordStore.delete).toHaveBeenLastCalledWith(email); + }); +}); diff --git a/test/unit/identity/interaction/password/DeletePasswordHandler.test.ts b/test/unit/identity/interaction/password/DeletePasswordHandler.test.ts new file mode 100644 index 000000000..578586511 --- /dev/null +++ b/test/unit/identity/interaction/password/DeletePasswordHandler.test.ts @@ -0,0 +1,82 @@ +import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; + +import { DeletePasswordHandler } from '../../../../../src/identity/interaction/password/DeletePasswordHandler'; +import { PASSWORD_METHOD } from '../../../../../src/identity/interaction/password/util/PasswordStore'; +import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; +import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +describe('A DeletePasswordHandler', (): void => { + const accountId = 'accountId'; + const email = 'example@example.com'; + const target = { path: 'http://example.com/.account/password' }; + let accountStore: jest.Mocked; + let passwordStore: jest.Mocked; + let handler: DeletePasswordHandler; + + beforeEach(async(): Promise => { + accountStore = mockAccountStore(); + accountStore.get.mockImplementation(async(id: string): Promise => { + const account = createAccount(id); + account.logins[PASSWORD_METHOD] = { [email]: target.path }; + return account; + }); + + passwordStore = { + delete: jest.fn(), + } as any; + + handler = new DeletePasswordHandler(accountStore, passwordStore); + }); + + it('deletes the token.', async(): Promise => { + await expect(handler.handle({ target, accountId } as any)).resolves.toEqual({ json: {}}); + // Once to find initial account and once for backup during `safeUpdate` + expect(accountStore.get).toHaveBeenCalledTimes(2); + expect(accountStore.get).toHaveBeenNthCalledWith(1, accountId); + expect(accountStore.get).toHaveBeenNthCalledWith(2, accountId); + expect(accountStore.update).toHaveBeenCalledTimes(1); + const account: Account = await accountStore.get.mock.results[0].value; + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(account.logins[PASSWORD_METHOD]![email]).toBeUndefined(); + expect(passwordStore.delete).toHaveBeenCalledTimes(1); + expect(passwordStore.delete).toHaveBeenLastCalledWith(email); + }); + + it('throws a 404 if there are no logins.', async(): Promise => { + accountStore.get.mockResolvedValueOnce(createAccount()); + await expect(handler.handle({ target, accountId } as any)).rejects.toThrow(NotFoundHttpError); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(accountId); + expect(accountStore.update).toHaveBeenCalledTimes(0); + expect(passwordStore.delete).toHaveBeenCalledTimes(0); + }); + + it('throws a 404 if there is no such token.', async(): Promise => { + const account = createAccount(accountId); + account.logins[PASSWORD_METHOD] = {}; + accountStore.get.mockResolvedValueOnce(account); + await expect(handler.handle({ target, accountId } as any)).rejects.toThrow(NotFoundHttpError); + expect(accountStore.get).toHaveBeenCalledTimes(1); + expect(accountStore.get).toHaveBeenLastCalledWith(accountId); + expect(accountStore.update).toHaveBeenCalledTimes(0); + expect(passwordStore.delete).toHaveBeenCalledTimes(0); + }); + + it('reverts the changes if there was a data error.', async(): Promise => { + const error = new Error('bad data'); + passwordStore.delete.mockRejectedValueOnce(error); + await expect(handler.handle({ target, accountId } as any)).rejects.toThrow(error); + expect(accountStore.get).toHaveBeenCalledTimes(2); + expect(accountStore.get).toHaveBeenNthCalledWith(1, accountId); + expect(accountStore.get).toHaveBeenNthCalledWith(2, accountId); + expect(accountStore.update).toHaveBeenCalledTimes(2); + expect(accountStore.update).toHaveBeenNthCalledWith(1, await accountStore.get.mock.results[0].value); + expect(accountStore.update).toHaveBeenNthCalledWith(2, expect.objectContaining({ + logins: { [PASSWORD_METHOD]: { [email]: target.path }}, + })); + expect(passwordStore.delete).toHaveBeenCalledTimes(1); + expect(passwordStore.delete).toHaveBeenLastCalledWith(email); + }); +}); diff --git a/test/unit/identity/interaction/password/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/password/ForgotPasswordHandler.test.ts new file mode 100644 index 000000000..09f38b258 --- /dev/null +++ b/test/unit/identity/interaction/password/ForgotPasswordHandler.test.ts @@ -0,0 +1,89 @@ +import { ForgotPasswordHandler } from '../../../../../src/identity/interaction/password/ForgotPasswordHandler'; +import type { EmailSender } from '../../../../../src/identity/interaction/password/util/EmailSender'; +import type { ForgotPasswordStore } from '../../../../../src/identity/interaction/password/util/ForgotPasswordStore'; +import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; +import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute'; +import type { TemplateEngine } from '../../../../../src/util/templates/TemplateEngine'; + +describe('A ForgotPasswordHandler', (): void => { + const accountId = 'accountId'; + let json: unknown; + const email = 'test@test.email'; + const recordId = '123456'; + const html = `Reset Password`; + let passwordStore: jest.Mocked; + let forgotPasswordStore: jest.Mocked; + let templateEngine: TemplateEngine<{ resetLink: string }>; + let resetRoute: jest.Mocked; + let emailSender: jest.Mocked; + let handler: ForgotPasswordHandler; + + beforeEach(async(): Promise => { + json = { email }; + + passwordStore = { + get: jest.fn().mockResolvedValue(accountId), + } as any; + + forgotPasswordStore = { + generate: jest.fn().mockResolvedValue(recordId), + } as any; + + templateEngine = { + handleSafe: jest.fn().mockResolvedValue(html), + } as any; + + resetRoute = { + getPath: jest.fn().mockReturnValue('http://test.com/base/idp/resetpassword/'), + matchPath: jest.fn(), + }; + + emailSender = { + handleSafe: jest.fn(), + } as any; + + handler = new ForgotPasswordHandler({ + passwordStore, + forgotPasswordStore, + templateEngine, + emailSender, + resetRoute, + }); + }); + + it('requires specific input fields.', async(): Promise => { + await expect(handler.getView()).resolves.toEqual({ + json: { + fields: { + email: { + required: true, + type: 'string', + }, + }, + }, + }); + }); + + it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise => { + passwordStore.get.mockResolvedValueOnce(undefined); + await expect(handler.handle({ json } as any)).resolves.toEqual({ json: { email }}); + expect(emailSender.handleSafe).toHaveBeenCalledTimes(0); + }); + + it('sends a mail if a ForgotPassword record could be generated.', async(): Promise => { + await expect(handler.handle({ json } as any)).resolves.toEqual({ json: { email }}); + expect(emailSender.handleSafe).toHaveBeenCalledTimes(1); + expect(emailSender.handleSafe).toHaveBeenLastCalledWith({ + recipient: email, + subject: 'Reset your password', + text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/?rid=${recordId}`, + html, + }); + }); + + it('catches the error if there was an issue sending the mail.', async(): Promise => { + emailSender.handleSafe.mockRejectedValueOnce(new Error('bad data')); + await expect(handler.handle({ json } as any)).resolves.toEqual({ json: { email }}); + expect(emailSender.handleSafe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit/identity/interaction/password/PasswordIdRoute.test.ts b/test/unit/identity/interaction/password/PasswordIdRoute.test.ts new file mode 100644 index 000000000..97f642e7f --- /dev/null +++ b/test/unit/identity/interaction/password/PasswordIdRoute.test.ts @@ -0,0 +1,14 @@ +import { BaseAccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute'; +import { BasePasswordIdRoute } from '../../../../../src/identity/interaction/password/util/PasswordIdRoute'; +import { + AbsolutePathInteractionRoute, +} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; + +describe('A BasePasswordIdRoute', (): void => { + it('uses the Password ID key.', async(): Promise => { + const passwordIdRoute = new BasePasswordIdRoute(new BaseAccountIdRoute( + new AbsolutePathInteractionRoute('http://example.com/'), + )); + expect(passwordIdRoute.matchPath('http://example.com/123/456/')).toEqual({ accountId: '123', passwordId: '456' }); + }); +}); diff --git a/test/unit/identity/interaction/password/PasswordLoginHandler.test.ts b/test/unit/identity/interaction/password/PasswordLoginHandler.test.ts new file mode 100644 index 000000000..551cbf1eb --- /dev/null +++ b/test/unit/identity/interaction/password/PasswordLoginHandler.test.ts @@ -0,0 +1,54 @@ +import { PasswordLoginHandler } from '../../../../../src/identity/interaction/password/PasswordLoginHandler'; +import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; + +describe('A PasswordLoginHandler', (): void => { + let json: unknown; + const accountId = 'accountId'; + const email = 'alice@test.email'; + const password = 'supersecret!'; + let passwordStore: jest.Mocked; + let handler: PasswordLoginHandler; + + beforeEach(async(): Promise => { + json = { email, password }; + + passwordStore = { + authenticate: jest.fn().mockResolvedValue(accountId), + } as any; + + handler = new PasswordLoginHandler({ + passwordStore, + accountStore: {} as any, + accountRoute: {} as any, + cookieStore: {} as any, + }); + }); + + it('requires specific input fields.', async(): Promise => { + await expect(handler.getView()).resolves.toEqual({ + json: { + fields: { + email: { + required: true, + type: 'string', + }, + password: { + required: true, + type: 'string', + }, + remember: { + required: false, + type: 'boolean', + }, + }, + }, + }); + }); + + it('logs the user in.', async(): Promise => { + await expect(handler.login({ json } as any)).resolves.toEqual({ json: { accountId, remember: false }}); + + expect(passwordStore.authenticate).toHaveBeenCalledTimes(1); + expect(passwordStore.authenticate).toHaveBeenLastCalledWith(email, password); + }); +}); diff --git a/test/unit/identity/interaction/password/ResetPasswordHandler.test.ts b/test/unit/identity/interaction/password/ResetPasswordHandler.test.ts new file mode 100644 index 000000000..3f7b7cec3 --- /dev/null +++ b/test/unit/identity/interaction/password/ResetPasswordHandler.test.ts @@ -0,0 +1,64 @@ +import { ResetPasswordHandler } from '../../../../../src/identity/interaction/password/ResetPasswordHandler'; +import type { ForgotPasswordStore } from '../../../../../src/identity/interaction/password/util/ForgotPasswordStore'; +import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; + +describe('A ResetPasswordHandler', (): void => { + let json: unknown; + const email = 'test@test.email'; + const password = 'newsecret!'; + const recordId = '123456'; + let passwordStore: jest.Mocked; + let forgotPasswordStore: jest.Mocked; + let handler: ResetPasswordHandler; + + beforeEach(async(): Promise => { + json = { password, recordId }; + + passwordStore = { + update: jest.fn(), + } as any; + + forgotPasswordStore = { + get: jest.fn().mockResolvedValue(email), + delete: jest.fn(), + } as any; + + handler = new ResetPasswordHandler(passwordStore, forgotPasswordStore); + }); + + it('requires specific input fields.', async(): Promise => { + await expect(handler.getView()).resolves.toEqual({ + json: { + fields: { + recordId: { + required: true, + type: 'string', + }, + password: { + required: true, + type: 'string', + }, + }, + }, + }); + }); + + it('can reset a password.', async(): Promise => { + await expect(handler.handle({ json } as any)).resolves.toEqual({ json: {}}); + expect(forgotPasswordStore.get).toHaveBeenCalledTimes(1); + expect(forgotPasswordStore.get).toHaveBeenLastCalledWith(recordId); + expect(forgotPasswordStore.delete).toHaveBeenCalledTimes(1); + expect(forgotPasswordStore.delete).toHaveBeenLastCalledWith(recordId); + expect(passwordStore.update).toHaveBeenCalledTimes(1); + expect(passwordStore.update).toHaveBeenLastCalledWith(email, password); + }); + + it('throws an error if no matching email was found.', async(): Promise => { + forgotPasswordStore.get.mockResolvedValueOnce(undefined); + await expect(handler.handle({ json } as any)).rejects.toThrow('This reset password link is no longer valid.'); + expect(forgotPasswordStore.get).toHaveBeenCalledTimes(1); + expect(forgotPasswordStore.get).toHaveBeenLastCalledWith(recordId); + expect(forgotPasswordStore.delete).toHaveBeenCalledTimes(0); + expect(passwordStore.update).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/identity/interaction/password/UpdatePasswordHandler.test.ts b/test/unit/identity/interaction/password/UpdatePasswordHandler.test.ts new file mode 100644 index 000000000..76023330b --- /dev/null +++ b/test/unit/identity/interaction/password/UpdatePasswordHandler.test.ts @@ -0,0 +1,67 @@ +import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { UpdatePasswordHandler } from '../../../../../src/identity/interaction/password/UpdatePasswordHandler'; +import { PASSWORD_METHOD } from '../../../../../src/identity/interaction/password/util/PasswordStore'; +import type { PasswordStore } from '../../../../../src/identity/interaction/password/util/PasswordStore'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +describe('An UpdatePasswordHandler', (): void => { + let account: Account; + let json: unknown; + const email = 'email@example.com'; + const target = { path: 'http://example.com/.account/password' }; + const oldPassword = 'oldPassword!'; + const newPassword = 'newPassword!'; + let accountStore: jest.Mocked; + let passwordStore: jest.Mocked; + let handler: UpdatePasswordHandler; + + beforeEach(async(): Promise => { + json = { oldPassword, newPassword }; + + account = createAccount(); + account.logins[PASSWORD_METHOD] = { [email]: target.path }; + accountStore = mockAccountStore(account); + + passwordStore = { + authenticate: jest.fn(), + update: jest.fn(), + } as any; + + handler = new UpdatePasswordHandler(accountStore, passwordStore); + }); + + it('requires specific input fields.', async(): Promise => { + await expect(handler.getView()).resolves.toEqual({ + json: { + fields: { + oldPassword: { + required: true, + type: 'string', + }, + newPassword: { + required: true, + type: 'string', + }, + }, + }, + }); + }); + + it('updates the password.', async(): Promise => { + await expect(handler.handle({ json, accountId: account.id, target } as any)).resolves.toEqual({ json: {}}); + expect(passwordStore.authenticate).toHaveBeenCalledTimes(1); + expect(passwordStore.authenticate).toHaveBeenLastCalledWith(email, oldPassword); + expect(passwordStore.update).toHaveBeenCalledTimes(1); + expect(passwordStore.update).toHaveBeenLastCalledWith(email, newPassword); + }); + + it('errors if authentication fails.', async(): Promise => { + passwordStore.authenticate.mockRejectedValueOnce(new Error('bad data')); + await expect(handler.handle({ json, accountId: account.id, target } as any)) + .rejects.toThrow('Old password is invalid.'); + expect(passwordStore.authenticate).toHaveBeenCalledTimes(1); + expect(passwordStore.authenticate).toHaveBeenLastCalledWith(email, oldPassword); + expect(passwordStore.update).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/identity/interaction/email-password/util/BaseEmailSender.test.ts b/test/unit/identity/interaction/password/util/BaseEmailSender.test.ts similarity index 86% rename from test/unit/identity/interaction/email-password/util/BaseEmailSender.test.ts rename to test/unit/identity/interaction/password/util/BaseEmailSender.test.ts index 4a2839f29..e00ed6b96 100644 --- a/test/unit/identity/interaction/email-password/util/BaseEmailSender.test.ts +++ b/test/unit/identity/interaction/password/util/BaseEmailSender.test.ts @@ -1,6 +1,11 @@ -import type { EmailSenderArgs } from '../../../../../../src/identity/interaction/email-password/util/BaseEmailSender'; -import { BaseEmailSender } from '../../../../../../src/identity/interaction/email-password/util/BaseEmailSender'; -import type { EmailArgs } from '../../../../../../src/identity/interaction/email-password/util/EmailSender'; +import type { + EmailSenderArgs, +} from '../../../../../../src/identity/interaction/password/util/BaseEmailSender'; +import { + BaseEmailSender, +} from '../../../../../../src/identity/interaction/password/util/BaseEmailSender'; +import type { EmailArgs } from '../../../../../../src/identity/interaction/password/util/EmailSender'; + jest.mock('nodemailer'); describe('A BaseEmailSender', (): void => { diff --git a/test/unit/identity/interaction/password/util/BaseForgotPasswordStore.test.ts b/test/unit/identity/interaction/password/util/BaseForgotPasswordStore.test.ts new file mode 100644 index 000000000..021a0372b --- /dev/null +++ b/test/unit/identity/interaction/password/util/BaseForgotPasswordStore.test.ts @@ -0,0 +1,41 @@ +import { + BaseForgotPasswordStore, +} from '../../../../../../src/identity/interaction/password/util/BaseForgotPasswordStore'; +import type { ExpiringStorage } from '../../../../../../src/storage/keyvalue/ExpiringStorage'; + +const record = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3'; +jest.mock('uuid', (): any => ({ v4: (): string => record })); + +describe('A BaseForgotPasswordStore', (): void => { + const email = 'email@example.com'; + let storage: jest.Mocked>; + let store: BaseForgotPasswordStore; + + beforeEach(async(): Promise => { + storage = { + get: jest.fn().mockResolvedValue(email), + set: jest.fn(), + delete: jest.fn(), + } as any; + + store = new BaseForgotPasswordStore(storage); + }); + + it('can create new records.', async(): Promise => { + await expect(store.generate(email)).resolves.toBe(record); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith(record, email, 15 * 60 * 1000); + }); + + it('returns the matching email.', async(): Promise => { + await expect(store.get(record)).resolves.toBe(email); + expect(storage.get).toHaveBeenCalledTimes(1); + expect(storage.get).toHaveBeenLastCalledWith(record); + }); + + it('can delete records.', async(): Promise => { + await expect(store.delete(record)).resolves.toBeUndefined(); + expect(storage.delete).toHaveBeenCalledTimes(1); + expect(storage.delete).toHaveBeenLastCalledWith(record); + }); +}); diff --git a/test/unit/identity/interaction/password/util/BasePasswordStore.test.ts b/test/unit/identity/interaction/password/util/BasePasswordStore.test.ts new file mode 100644 index 000000000..a3b07623a --- /dev/null +++ b/test/unit/identity/interaction/password/util/BasePasswordStore.test.ts @@ -0,0 +1,87 @@ +import { BasePasswordStore } from '../../../../../../src/identity/interaction/password/util/BasePasswordStore'; +import type { + LoginPayload, +} from '../../../../../../src/identity/interaction/password/util/BasePasswordStore'; +import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; + +describe('A BasePasswordStore', (): void => { + const email = 'test@example.com'; + const accountId = 'accountId'; + const password = 'password!'; + let storage: jest.Mocked>; + let store: BasePasswordStore; + + beforeEach(async(): Promise => { + const map = new Map(); + storage = { + get: jest.fn((id: string): any => map.get(id)), + set: jest.fn((id: string, value: any): any => map.set(id, value)), + delete: jest.fn((id: string): any => map.delete(id)), + } as any; + + store = new BasePasswordStore(storage); + }); + + it('can create logins.', async(): Promise => { + await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); + }); + + it('errors when creating a second login for an email.', async(): Promise => { + await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); + await expect(store.create(email, accountId, 'diffPass')) + .rejects.toThrow('here already is a login for this e-mail address.'); + }); + + it('errors when authenticating a non-existent login.', async(): Promise => { + await expect(store.authenticate(email, password)).rejects.toThrow('Login does not exist.'); + }); + + it('errors when authenticating an unverified login.', async(): Promise => { + await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); + await expect(store.authenticate(email, password)).rejects.toThrow('Login still needs to be verified.'); + }); + + it('errors when verifying a non-existent login.', async(): Promise => { + await expect(store.confirmVerification(email)).rejects.toThrow('Login does not exist.'); + }); + + it('errors when authenticating with the wrong password.', async(): Promise => { + await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); + await expect(store.confirmVerification(email)).resolves.toBeUndefined(); + await expect(store.authenticate(email, 'wrongPassword')).rejects.toThrow('Incorrect password.'); + }); + + it('can authenticate.', async(): Promise => { + await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); + await expect(store.confirmVerification(email)).resolves.toBeUndefined(); + await expect(store.authenticate(email, password)).resolves.toBe(accountId); + }); + + it('errors when changing the password of a non-existent account.', async(): Promise => { + await expect(store.update(email, password)).rejects.toThrow('Login does not exist.'); + }); + + it('can change the password.', async(): Promise => { + const newPassword = 'newPassword!'; + await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); + await expect(store.confirmVerification(email)).resolves.toBeUndefined(); + await expect(store.update(email, newPassword)).resolves.toBeUndefined(); + await expect(store.authenticate(email, newPassword)).resolves.toBe(accountId); + }); + + it('can get the accountId.', async(): Promise => { + await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); + await expect(store.get(email)).resolves.toEqual(accountId); + }); + + it('can delete a login.', async(): Promise => { + await expect(store.create(email, accountId, password)).resolves.toBeUndefined(); + await expect(store.delete(email)).resolves.toBe(true); + await expect(store.authenticate(email, password)).rejects.toThrow('Login does not exist.'); + await expect(store.get(accountId)).resolves.toBeUndefined(); + }); + + it('does nothing when deleting non-existent login.', async(): Promise => { + await expect(store.delete(email)).resolves.toBe(false); + }); +}); diff --git a/test/unit/identity/interaction/pod/CreatePodHandler.test.ts b/test/unit/identity/interaction/pod/CreatePodHandler.test.ts new file mode 100644 index 000000000..215b1fc44 --- /dev/null +++ b/test/unit/identity/interaction/pod/CreatePodHandler.test.ts @@ -0,0 +1,178 @@ +import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { CreatePodHandler } from '../../../../../src/identity/interaction/pod/CreatePodHandler'; +import type { PodStore } from '../../../../../src/identity/interaction/pod/util/PodStore'; +import type { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore'; +import type { IdentifierGenerator } from '../../../../../src/pods/generate/IdentifierGenerator'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +describe('A CreatePodHandler', (): void => { + const name = 'name'; + const webId = 'http://example.com/other/webId#me'; + const accountId = 'accountId'; + let json: unknown; + const baseUrl = 'http://example.com/'; + const relativeWebIdPath = '/profile/card#me'; + const podUrl = 'http://example.com/name/'; + const generatedWebId = 'http://example.com/name/profile/card#me'; + const webIdResource = 'http://example.com/.account/webID'; + const podResource = 'http://example.com/.account/pod'; + let identifierGenerator: jest.Mocked; + let accountStore: jest.Mocked; + let webIdStore: jest.Mocked; + let podStore: jest.Mocked; + let handler: CreatePodHandler; + + beforeEach(async(): Promise => { + json = { + name, + }; + + identifierGenerator = { + generate: jest.fn().mockReturnValue({ path: podUrl }), + extractPod: jest.fn(), + }; + + accountStore = mockAccountStore(); + accountStore.get.mockImplementation(async(id: string): Promise => createAccount(id)); + + webIdStore = { + get: jest.fn(), + add: jest.fn().mockResolvedValue(webIdResource), + delete: jest.fn(), + }; + + podStore = { + create: jest.fn().mockResolvedValue(podResource), + }; + + handler = new CreatePodHandler( + { accountStore, webIdStore, podStore, baseUrl, relativeWebIdPath, identifierGenerator, allowRoot: false }, + ); + }); + + it('requires specific input fields.', async(): Promise => { + await expect(handler.getView()).resolves.toEqual({ + json: { + fields: { + name: { + required: true, + type: 'string', + }, + settings: { + required: false, + type: 'object', + fields: { + webId: { + required: false, + type: 'string', + }, + }, + }, + }, + }, + }); + }); + + it('generates a pod and WebID.', async(): Promise => { + await expect(handler.handle({ json, accountId } as any)).resolves.toEqual({ json: { + pod: podUrl, webId: generatedWebId, podResource, webIdResource, + }}); + expect(webIdStore.add).toHaveBeenCalledTimes(1); + expect(webIdStore.add).toHaveBeenLastCalledWith(generatedWebId, await accountStore.get.mock.results[0].value); + expect(podStore.create).toHaveBeenCalledTimes(1); + expect(podStore.create).toHaveBeenLastCalledWith(await accountStore.get.mock.results[0].value, { + base: { path: podUrl }, + webId: generatedWebId, + oidcIssuer: baseUrl, + }, false); + }); + + it('can use an external WebID for the pod generation.', async(): Promise => { + json = { name, settings: { webId }}; + + await expect(handler.handle({ json, accountId } as any)).resolves.toEqual({ json: { + pod: podUrl, webId, podResource, + }}); + expect(webIdStore.add).toHaveBeenCalledTimes(0); + expect(podStore.create).toHaveBeenCalledTimes(1); + expect(podStore.create).toHaveBeenLastCalledWith(await accountStore.get.mock.results[0].value, { + base: { path: podUrl }, + webId, + }, false); + }); + + it('errors if the account is already linked to the WebID that would be generated.', async(): Promise => { + const account = createAccount(); + account.webIds[generatedWebId] = 'http://example.com/resource'; + accountStore.get.mockResolvedValueOnce(account); + await expect(handler.handle({ json, accountId } as any)) + .rejects.toThrow(`${generatedWebId} is already registered to this account.`); + expect(webIdStore.add).toHaveBeenCalledTimes(0); + expect(podStore.create).toHaveBeenCalledTimes(0); + }); + + it('undoes any changes if something goes wrong creating the pod.', async(): Promise => { + const error = new Error('bad data'); + podStore.create.mockRejectedValueOnce(error); + + await expect(handler.handle({ json, accountId } as any)).rejects.toBe(error); + + expect(webIdStore.add).toHaveBeenCalledTimes(1); + expect(webIdStore.add).toHaveBeenLastCalledWith(generatedWebId, await accountStore.get.mock.results[0].value); + expect(podStore.create).toHaveBeenCalledTimes(1); + expect(podStore.create).toHaveBeenLastCalledWith(await accountStore.get.mock.results[0].value, { + base: { path: podUrl }, + webId: generatedWebId, + oidcIssuer: baseUrl, + }, false); + expect(webIdStore.delete).toHaveBeenCalledTimes(1); + expect(webIdStore.add).toHaveBeenLastCalledWith(generatedWebId, await accountStore.get.mock.results[1].value); + }); + + describe('allowing root pods', (): void => { + beforeEach(async(): Promise => { + handler = new CreatePodHandler( + { accountStore, webIdStore, podStore, baseUrl, relativeWebIdPath, identifierGenerator, allowRoot: true }, + ); + }); + + it('does not require a name.', async(): Promise => { + await expect(handler.getView()).resolves.toEqual({ + json: { + fields: { + name: { + required: false, + type: 'string', + }, + settings: { + required: false, + type: 'object', + fields: { + webId: { + required: false, + type: 'string', + }, + }, + }, + }, + }, + }); + }); + + it('generates a pod and WebID in the root.', async(): Promise => { + await expect(handler.handle({ json: {}, accountId } as any)).resolves.toEqual({ json: { + pod: baseUrl, webId: `${baseUrl}profile/card#me`, podResource, webIdResource, + }}); + expect(webIdStore.add).toHaveBeenCalledTimes(1); + expect(webIdStore.add) + .toHaveBeenLastCalledWith(`${baseUrl}profile/card#me`, await accountStore.get.mock.results[0].value); + expect(podStore.create).toHaveBeenCalledTimes(1); + expect(podStore.create).toHaveBeenLastCalledWith(await accountStore.get.mock.results[0].value, { + base: { path: baseUrl }, + webId: `${baseUrl}profile/card#me`, + oidcIssuer: baseUrl, + }, true); + }); + }); +}); diff --git a/test/unit/identity/interaction/pod/PodIdRoute.test.ts b/test/unit/identity/interaction/pod/PodIdRoute.test.ts new file mode 100644 index 000000000..375c7ca0a --- /dev/null +++ b/test/unit/identity/interaction/pod/PodIdRoute.test.ts @@ -0,0 +1,14 @@ +import { BaseAccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute'; +import { BasePodIdRoute } from '../../../../../src/identity/interaction/pod/PodIdRoute'; +import { + AbsolutePathInteractionRoute, +} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; + +describe('A BasePodIdRoute', (): void => { + it('uses the Pod ID key.', async(): Promise => { + const podIdRoute = new BasePodIdRoute(new BaseAccountIdRoute( + new AbsolutePathInteractionRoute('http://example.com/'), + )); + expect(podIdRoute.matchPath('http://example.com/123/456/')).toEqual({ accountId: '123', podId: '456' }); + }); +}); diff --git a/test/unit/identity/interaction/pod/util/BasePodStore.test.ts b/test/unit/identity/interaction/pod/util/BasePodStore.test.ts new file mode 100644 index 000000000..dca7ec050 --- /dev/null +++ b/test/unit/identity/interaction/pod/util/BasePodStore.test.ts @@ -0,0 +1,50 @@ +import type { Account } from '../../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../../src/identity/interaction/account/util/AccountStore'; +import type { PodIdRoute } from '../../../../../../src/identity/interaction/pod/PodIdRoute'; +import { BasePodStore } from '../../../../../../src/identity/interaction/pod/util/BasePodStore'; +import type { PodManager } from '../../../../../../src/pods/PodManager'; +import type { PodSettings } from '../../../../../../src/pods/settings/PodSettings'; +import { createAccount, mockAccountStore } from '../../../../../util/AccountUtil'; + +describe('A BasePodStore', (): void => { + let account: Account; + const settings: PodSettings = { webId: 'http://example.com/card#me', base: { path: 'http://example.com/foo' }}; + const route: PodIdRoute = { + getPath: (): string => 'http://example.com/.account/resource', + matchPath: (): any => ({}), + }; + let accountStore: jest.Mocked; + let manager: jest.Mocked; + let store: BasePodStore; + + beforeEach(async(): Promise => { + account = createAccount(); + + accountStore = mockAccountStore(createAccount()); + + manager = { + createPod: jest.fn(), + }; + + store = new BasePodStore(accountStore, route, manager); + }); + + it('calls the pod manager to create a pod.', async(): Promise => { + await expect(store.create(account, settings, false)).resolves.toBe('http://example.com/.account/resource'); + expect(manager.createPod).toHaveBeenCalledTimes(1); + expect(manager.createPod).toHaveBeenLastCalledWith(settings, false); + expect(accountStore.update).toHaveBeenCalledTimes(1); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(account.pods['http://example.com/foo']).toBe('http://example.com/.account/resource'); + }); + + it('does not update the account if something goes wrong.', async(): Promise => { + manager.createPod.mockRejectedValueOnce(new Error('bad data')); + await expect(store.create(account, settings, false)).rejects.toThrow('Pod creation failed: bad data'); + expect(manager.createPod).toHaveBeenCalledTimes(1); + expect(manager.createPod).toHaveBeenLastCalledWith(settings, false); + expect(accountStore.update).toHaveBeenCalledTimes(2); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(account.pods).toEqual({}); + }); +}); diff --git a/test/unit/identity/interaction/routing/AbsolutePathInteractionRoute.test.ts b/test/unit/identity/interaction/routing/AbsolutePathInteractionRoute.test.ts index bed58c07e..ee931f92b 100644 --- a/test/unit/identity/interaction/routing/AbsolutePathInteractionRoute.test.ts +++ b/test/unit/identity/interaction/routing/AbsolutePathInteractionRoute.test.ts @@ -9,4 +9,9 @@ describe('An AbsolutePathInteractionRoute', (): void => { it('returns the given path.', async(): Promise => { expect(route.getPath()).toBe('http://example.com/idp/path/'); }); + + it('matches a path if it is identical to the stored path.', async(): Promise => { + expect(route.matchPath(path)).toEqual({}); + expect(route.matchPath('http://example.com/somewhere/else')).toBeUndefined(); + }); }); diff --git a/test/unit/identity/interaction/routing/AuthorizedRouteHandler.test.ts b/test/unit/identity/interaction/routing/AuthorizedRouteHandler.test.ts new file mode 100644 index 000000000..299813327 --- /dev/null +++ b/test/unit/identity/interaction/routing/AuthorizedRouteHandler.test.ts @@ -0,0 +1,56 @@ +import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; +import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier'; +import type { AccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute'; +import type { JsonInteractionHandler, + JsonInteractionHandlerInput } from '../../../../../src/identity/interaction/JsonInteractionHandler'; +import { AuthorizedRouteHandler } from '../../../../../src/identity/interaction/routing/AuthorizedRouteHandler'; +import { ForbiddenHttpError } from '../../../../../src/util/errors/ForbiddenHttpError'; +import { UnauthorizedHttpError } from '../../../../../src/util/errors/UnauthorizedHttpError'; + +describe('An AuthorizedRouteHandler', (): void => { + const accountId = 'accountId'; + const target: ResourceIdentifier = { path: 'http://example.com/foo' }; + let input: JsonInteractionHandlerInput; + let route: jest.Mocked; + let source: jest.Mocked; + let handler: AuthorizedRouteHandler; + + beforeEach(async(): Promise => { + input = { + target, + json: { data: 'data' }, + metadata: new RepresentationMetadata(), + method: 'GET', + accountId, + }; + + route = { + matchPath: jest.fn().mockReturnValue({ accountId }), + getPath: jest.fn(), + }; + + source = { + handle: jest.fn().mockResolvedValue('response'), + } as any; + + handler = new AuthorizedRouteHandler(route, source); + }); + + it('calls the source handler with the input.', async(): Promise => { + await expect(handler.handle(input)).resolves.toBe('response'); + expect(source.handle).toHaveBeenCalledTimes(1); + expect(source.handle).toHaveBeenLastCalledWith(input); + }); + + it('errors if there is no account ID in the input.', async(): Promise => { + delete input.accountId; + await expect(handler.handle(input)).rejects.toThrow(UnauthorizedHttpError); + expect(source.handle).toHaveBeenCalledTimes(0); + }); + + it('errors if the account ID does not match the route result.', async(): Promise => { + route.matchPath.mockReturnValueOnce({ accountId: 'otherId' }); + await expect(handler.handle(input)).rejects.toThrow(ForbiddenHttpError); + expect(source.handle).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/identity/interaction/routing/IdInteractionRoute.test.ts b/test/unit/identity/interaction/routing/IdInteractionRoute.test.ts new file mode 100644 index 000000000..6c35b1d37 --- /dev/null +++ b/test/unit/identity/interaction/routing/IdInteractionRoute.test.ts @@ -0,0 +1,48 @@ +import { IdInteractionRoute } from '../../../../../src/identity/interaction/routing/IdInteractionRoute'; +import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute'; +import { InternalServerError } from '../../../../../src/util/errors/InternalServerError'; + +describe('An IdInteractionRoute', (): void => { + const idName = 'id'; + let base: jest.Mocked>; + let route: IdInteractionRoute<'base', 'id'>; + + beforeEach(async(): Promise => { + base = { + getPath: jest.fn().mockReturnValue('http://example.com/'), + matchPath: jest.fn().mockReturnValue({ base: 'base' }), + }; + + route = new IdInteractionRoute<'base', 'id'>(base, idName); + }); + + describe('#getPath', (): void => { + it('appends the identifier value to generate the path.', async(): Promise => { + expect(route.getPath({ base: 'base', id: '12345' })).toBe('http://example.com/12345/'); + }); + + it('errors if there is no input identifier.', async(): Promise => { + expect((): string => route.getPath({ base: 'base' } as any)).toThrow(InternalServerError); + }); + + it('can be configured not to add a slash at the end.', async(): Promise => { + route = new IdInteractionRoute<'base', 'id'>(base, idName, false); + expect(route.getPath({ base: 'base', id: '12345' })).toBe('http://example.com/12345'); + }); + }); + + describe('#matchPath', (): void => { + it('returns the matching values.', async(): Promise => { + expect(route.matchPath('http://example.com/1234/')).toEqual({ base: 'base', id: '1234' }); + }); + + it('returns undefined if there is no match.', async(): Promise => { + expect(route.matchPath('http://example.com/1234')).toBeUndefined(); + }); + + it('returns undefined if there is no base match.', async(): Promise => { + base.matchPath.mockReturnValueOnce(undefined); + expect(route.matchPath('http://example.com/1234/')).toBeUndefined(); + }); + }); +}); diff --git a/test/unit/identity/interaction/routing/InteractionRouteHandler.test.ts b/test/unit/identity/interaction/routing/InteractionRouteHandler.test.ts index cecc36cd9..dff16c952 100644 --- a/test/unit/identity/interaction/routing/InteractionRouteHandler.test.ts +++ b/test/unit/identity/interaction/routing/InteractionRouteHandler.test.ts @@ -1,53 +1,53 @@ -import type { Operation } from '../../../../../src/http/Operation'; -import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation'; -import type { Representation } from '../../../../../src/http/representation/Representation'; -import type { InteractionHandler } from '../../../../../src/identity/interaction/InteractionHandler'; +import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata'; +import type { JsonInteractionHandler, + JsonInteractionHandlerInput } from '../../../../../src/identity/interaction/JsonInteractionHandler'; import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute'; import { InteractionRouteHandler } from '../../../../../src/identity/interaction/routing/InteractionRouteHandler'; -import { APPLICATION_JSON } from '../../../../../src/util/ContentTypes'; import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; -import { createPostJsonOperation } from '../email-password/handler/Util'; describe('An InteractionRouteHandler', (): void => { - const path = 'http://example.com/idp/path/'; - let operation: Operation; - let representation: Representation; - let route: InteractionRoute; - let source: jest.Mocked; - let handler: InteractionRouteHandler; + const path = 'http://example.com/foo/'; + let input: JsonInteractionHandlerInput; + let route: jest.Mocked>; + let source: jest.Mocked; + let handler: InteractionRouteHandler>; beforeEach(async(): Promise => { - operation = createPostJsonOperation({}, path); - - representation = new BasicRepresentation(JSON.stringify({}), APPLICATION_JSON); + input = { + target: { path }, + json: { data: 'data' }, + metadata: new RepresentationMetadata(), + method: 'GET', + }; route = { getPath: jest.fn().mockReturnValue(path), + matchPath: jest.fn().mockReturnValue({ base: 'base' }), }; source = { canHandle: jest.fn(), - handle: jest.fn().mockResolvedValue(representation), + handle: jest.fn().mockResolvedValue('response'), } as any; handler = new InteractionRouteHandler(route, source); }); it('rejects other paths.', async(): Promise => { - operation = createPostJsonOperation({}, 'http://example.com/idp/otherPath/'); - await expect(handler.canHandle({ operation })).rejects.toThrow(NotFoundHttpError); + route.matchPath.mockReturnValueOnce(undefined); + await expect(handler.canHandle(input)).rejects.toThrow(NotFoundHttpError); }); it('rejects input its source cannot handle.', async(): Promise => { source.canHandle.mockRejectedValueOnce(new Error('bad data')); - await expect(handler.canHandle({ operation })).rejects.toThrow('bad data'); + await expect(handler.canHandle(input)).rejects.toThrow('bad data'); }); it('can handle requests its source can handle.', async(): Promise => { - await expect(handler.canHandle({ operation })).resolves.toBeUndefined(); + await expect(handler.canHandle(input)).resolves.toBeUndefined(); }); it('lets its source handle requests.', async(): Promise => { - await expect(handler.handle({ operation })).resolves.toBe(representation); + await expect(handler.handle(input)).resolves.toBe('response'); }); }); diff --git a/test/unit/identity/interaction/routing/RelativePathInteractionRoute.test.ts b/test/unit/identity/interaction/routing/RelativePathInteractionRoute.test.ts index b8991202c..1960f5679 100644 --- a/test/unit/identity/interaction/routing/RelativePathInteractionRoute.test.ts +++ b/test/unit/identity/interaction/routing/RelativePathInteractionRoute.test.ts @@ -2,23 +2,34 @@ import type { InteractionRoute } from '../../../../../src/identity/interaction/r import { RelativePathInteractionRoute, } from '../../../../../src/identity/interaction/routing/RelativePathInteractionRoute'; +import { InternalServerError } from '../../../../../src/util/errors/InternalServerError'; describe('A RelativePathInteractionRoute', (): void => { const relativePath = '/relative/'; - let route: jest.Mocked; - let relativeRoute: RelativePathInteractionRoute; + let route: jest.Mocked>; + let relativeRoute: RelativePathInteractionRoute<'base'>; beforeEach(async(): Promise => { route = { getPath: jest.fn().mockReturnValue('http://example.com/'), + matchPath: jest.fn().mockReturnValue({ base: 'base' }), }; + + relativeRoute = new RelativePathInteractionRoute(route, relativePath); }); it('returns the joined path.', async(): Promise => { - relativeRoute = new RelativePathInteractionRoute(route, relativePath); expect(relativeRoute.getPath()).toBe('http://example.com/relative/'); + }); - relativeRoute = new RelativePathInteractionRoute('http://example.com/test/', relativePath); - expect(relativeRoute.getPath()).toBe('http://example.com/test/relative/'); + it('matches paths by checking if the tail matches the relative path.', async(): Promise => { + expect(relativeRoute.matchPath('http://example.com/relative/')).toEqual({ base: 'base' }); + + expect(relativeRoute.matchPath('http://example.com/relative')).toBeUndefined(); + }); + + it('errors if the base path does not end in a slash.', async(): Promise => { + route.getPath.mockReturnValueOnce('http://example.com/foo'); + expect((): string => relativeRoute.getPath()).toThrow(InternalServerError); }); }); diff --git a/test/unit/identity/interaction/webid/LinkWebIdHandler.test.ts b/test/unit/identity/interaction/webid/LinkWebIdHandler.test.ts new file mode 100644 index 000000000..a73a27400 --- /dev/null +++ b/test/unit/identity/interaction/webid/LinkWebIdHandler.test.ts @@ -0,0 +1,107 @@ +import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { LinkWebIdHandler } from '../../../../../src/identity/interaction/webid/LinkWebIdHandler'; +import type { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore'; +import type { WebIdLinkRoute } from '../../../../../src/identity/interaction/webid/WebIdLinkRoute'; +import type { OwnershipValidator } from '../../../../../src/identity/ownership/OwnershipValidator'; +import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError'; +import type { IdentifierStrategy } from '../../../../../src/util/identifiers/IdentifierStrategy'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +describe('A LinkWebIdHandler', (): void => { + let account: Account; + const accountId = 'accountId'; + const webId = 'http://example.com/profile/card#me'; + let json: unknown; + const resource = 'http://example.com/.account/link'; + const baseUrl = 'http://example.com/'; + let ownershipValidator: jest.Mocked; + let accountStore: jest.Mocked; + let webIdStore: jest.Mocked; + let webIdRoute: jest.Mocked; + let identifierStrategy: jest.Mocked; + let handler: LinkWebIdHandler; + + beforeEach(async(): Promise => { + json = { webId }; + + ownershipValidator = { + handleSafe: jest.fn(), + } as any; + + account = createAccount(); + accountStore = mockAccountStore(account); + + webIdStore = { + add: jest.fn().mockResolvedValue(resource), + } as any; + + identifierStrategy = { + contains: jest.fn().mockReturnValue(true), + } as any; + + handler = new LinkWebIdHandler({ + accountStore, + identifierStrategy, + webIdRoute, + webIdStore, + ownershipValidator, + baseUrl, + }); + }); + + it('requires a WebID as input.', async(): Promise => { + await expect(handler.getView()).resolves.toEqual({ + json: { + fields: { + webId: { + required: true, + type: 'string', + }, + }, + }, + }); + }); + + it('links the WebID.', async(): Promise => { + await expect(handler.handle({ accountId, json } as any)).resolves.toEqual({ + json: { resource, webId, oidcIssuer: baseUrl }, + }); + expect(webIdStore.add).toHaveBeenCalledTimes(1); + expect(webIdStore.add).toHaveBeenLastCalledWith(webId, account); + }); + + it('throws an error if the WebID is already registered.', async(): Promise => { + account.webIds[webId] = resource; + await expect(handler.handle({ accountId, json } as any)).rejects.toThrow(BadRequestHttpError); + expect(webIdStore.add).toHaveBeenCalledTimes(0); + }); + + it('checks if the WebID is in a pod owned by the account.', async(): Promise => { + account.pods['http://example.com/.account/pod/'] = resource; + await expect(handler.handle({ accountId, json } as any)).resolves.toEqual({ + json: { resource, webId, oidcIssuer: baseUrl }, + }); + expect(identifierStrategy.contains).toHaveBeenCalledTimes(1); + expect(identifierStrategy.contains) + .toHaveBeenCalledWith({ path: 'http://example.com/.account/pod/' }, { path: webId }, true); + expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(0); + }); + + it('calls the ownership validator if none of the pods contain the WebId.', async(): Promise => { + identifierStrategy.contains.mockReturnValue(false); + account.pods['http://example.com/.account/pod/'] = resource; + account.pods['http://example.com/.account/pod2/'] = resource; + + await expect(handler.handle({ accountId, json } as any)).resolves.toEqual({ + json: { resource, webId, oidcIssuer: baseUrl }, + }); + expect(identifierStrategy.contains).toHaveBeenCalledTimes(2); + expect(identifierStrategy.contains) + .toHaveBeenCalledWith({ path: 'http://example.com/.account/pod/' }, { path: webId }, true); + expect(identifierStrategy.contains) + .toHaveBeenCalledWith({ path: 'http://example.com/.account/pod2/' }, { path: webId }, true); + expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); + expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); + }); +}); diff --git a/test/unit/identity/interaction/webid/UnlinkWebIdHandler.test.ts b/test/unit/identity/interaction/webid/UnlinkWebIdHandler.test.ts new file mode 100644 index 000000000..97a02b69c --- /dev/null +++ b/test/unit/identity/interaction/webid/UnlinkWebIdHandler.test.ts @@ -0,0 +1,42 @@ +import type { Account } from '../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../src/identity/interaction/account/util/AccountStore'; +import { UnlinkWebIdHandler } from '../../../../../src/identity/interaction/webid/UnlinkWebIdHandler'; +import type { WebIdStore } from '../../../../../src/identity/interaction/webid/util/WebIdStore'; +import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; +import { createAccount, mockAccountStore } from '../../../../util/AccountUtil'; + +describe('A UnlinkWebIdHandler', (): void => { + const resource = 'http://example.com/.account/link'; + const webId = 'http://example.com/.account/card#me'; + const accountId = 'accountId'; + let account: Account; + let accountStore: jest.Mocked; + let webIdStore: jest.Mocked; + let handler: UnlinkWebIdHandler; + + beforeEach(async(): Promise => { + account = createAccount(accountId); + account.webIds[webId] = resource; + + accountStore = mockAccountStore(account); + + webIdStore = { + get: jest.fn(), + add: jest.fn(), + delete: jest.fn(), + }; + + handler = new UnlinkWebIdHandler(accountStore, webIdStore); + }); + + it('removes the WebID link.', async(): Promise => { + await expect(handler.handle({ target: { path: resource }, accountId } as any)).resolves.toEqual({ json: {}}); + expect(webIdStore.delete).toHaveBeenCalledTimes(1); + expect(webIdStore.delete).toHaveBeenLastCalledWith(webId, account); + }); + + it('errors if there is no matching link resource.', async(): Promise => { + delete account.webIds[webId]; + await expect(handler.handle({ target: { path: resource }, accountId } as any)).rejects.toThrow(NotFoundHttpError); + }); +}); diff --git a/test/unit/identity/interaction/webid/WebIdLinkRoute.test.ts b/test/unit/identity/interaction/webid/WebIdLinkRoute.test.ts new file mode 100644 index 000000000..be531203d --- /dev/null +++ b/test/unit/identity/interaction/webid/WebIdLinkRoute.test.ts @@ -0,0 +1,14 @@ +import { BaseAccountIdRoute } from '../../../../../src/identity/interaction/account/AccountIdRoute'; +import { + AbsolutePathInteractionRoute, +} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; +import { BaseWebIdLinkRoute } from '../../../../../src/identity/interaction/webid/WebIdLinkRoute'; + +describe('A WebIdLinkRoute', (): void => { + it('uses the WebID link key.', async(): Promise => { + const webIdLinkRoute = new BaseWebIdLinkRoute(new BaseAccountIdRoute( + new AbsolutePathInteractionRoute('http://example.com/'), + )); + expect(webIdLinkRoute.matchPath('http://example.com/123/456/')).toEqual({ accountId: '123', webIdLink: '456' }); + }); +}); diff --git a/test/unit/identity/interaction/webid/util/BaseWebIdStore.test.ts b/test/unit/identity/interaction/webid/util/BaseWebIdStore.test.ts new file mode 100644 index 000000000..f95d0f602 --- /dev/null +++ b/test/unit/identity/interaction/webid/util/BaseWebIdStore.test.ts @@ -0,0 +1,112 @@ +import type { Account } from '../../../../../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../../../../../src/identity/interaction/account/util/AccountStore'; +import { BaseWebIdStore } from '../../../../../../src/identity/interaction/webid/util/BaseWebIdStore'; +import type { WebIdLinkRoute } from '../../../../../../src/identity/interaction/webid/WebIdLinkRoute'; +import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage'; +import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError'; +import { createAccount, mockAccountStore } from '../../../../../util/AccountUtil'; + +describe('A BaseWebIdStore', (): void => { + const webId = 'http://example.com/card#me'; + let account: Account; + const route: WebIdLinkRoute = { + getPath: (): string => 'http://example.com/.account/resource', + matchPath: (): any => ({}), + }; + let accountStore: jest.Mocked; + let storage: jest.Mocked>; + let store: BaseWebIdStore; + + beforeEach(async(): Promise => { + account = createAccount(); + + accountStore = mockAccountStore(createAccount()); + + storage = { + get: jest.fn().mockResolvedValue([ account.id ]), + set: jest.fn(), + delete: jest.fn(), + } as any; + + store = new BaseWebIdStore(route, accountStore, storage); + }); + + it('returns the stored account identifiers.', async(): Promise => { + await expect(store.get(webId)).resolves.toEqual([ account.id ]); + }); + + it('returns an empty list if there are no matching idenfitiers.', async(): Promise => { + storage.get.mockResolvedValueOnce(undefined); + await expect(store.get(webId)).resolves.toEqual([]); + }); + + it('can add an account to the linked list.', async(): Promise => { + await expect(store.add(webId, account)).resolves.toBe('http://example.com/.account/resource'); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith(webId, [ account.id ]); + expect(accountStore.update).toHaveBeenCalledTimes(1); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(account.webIds[webId]).toBe('http://example.com/.account/resource'); + }); + + it('creates a new list if one did not exist yet.', async(): Promise => { + storage.get.mockResolvedValueOnce(undefined); + await expect(store.add(webId, account)).resolves.toBe('http://example.com/.account/resource'); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith(webId, [ account.id ]); + expect(accountStore.update).toHaveBeenCalledTimes(1); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(account.webIds[webId]).toBe('http://example.com/.account/resource'); + }); + + it('can not create a link if the WebID is already linked.', async(): Promise => { + account.webIds[webId] = 'resource'; + await expect(store.add(webId, account)).rejects.toThrow(BadRequestHttpError); + expect(storage.set).toHaveBeenCalledTimes(0); + expect(accountStore.update).toHaveBeenCalledTimes(0); + }); + + it('does not update the account if something goes wrong.', async(): Promise => { + storage.set.mockRejectedValueOnce(new Error('bad data')); + await expect(store.add(webId, account)).rejects.toThrow('bad data'); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith(webId, [ account.id ]); + expect(accountStore.update).toHaveBeenCalledTimes(2); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(account.webIds).toEqual({}); + }); + + it('can delete a link.', async(): Promise => { + await expect(store.delete(webId, account)).resolves.toBeUndefined(); + expect(storage.delete).toHaveBeenCalledTimes(1); + expect(storage.delete).toHaveBeenLastCalledWith(webId); + expect(accountStore.update).toHaveBeenCalledTimes(1); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(account.webIds).toEqual({}); + }); + + it('does not remove the entire list if there are still other entries.', async(): Promise => { + storage.get.mockResolvedValueOnce([ account.id, 'other-id' ]); + await expect(store.delete(webId, account)).resolves.toBeUndefined(); + expect(storage.set).toHaveBeenCalledTimes(1); + expect(storage.set).toHaveBeenLastCalledWith(webId, [ 'other-id' ]); + expect(accountStore.update).toHaveBeenCalledTimes(1); + expect(accountStore.update).toHaveBeenLastCalledWith(account); + expect(account.webIds).toEqual({}); + }); + + it('does not do anything if the the delete WebID target does not exist.', async(): Promise => { + storage.get.mockResolvedValueOnce(undefined); + await expect(store.delete('random-webId', account)).resolves.toBeUndefined(); + expect(storage.set).toHaveBeenCalledTimes(0); + expect(storage.delete).toHaveBeenCalledTimes(0); + expect(accountStore.update).toHaveBeenCalledTimes(0); + }); + + it('does not do anything if the the delete account target is not linked.', async(): Promise => { + await expect(store.delete(webId, { ...account, id: 'random-id' })).resolves.toBeUndefined(); + expect(storage.set).toHaveBeenCalledTimes(0); + expect(storage.delete).toHaveBeenCalledTimes(0); + expect(accountStore.update).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/init/SeededAccountInitializer.test.ts b/test/unit/init/SeededAccountInitializer.test.ts new file mode 100644 index 000000000..73532c98d --- /dev/null +++ b/test/unit/init/SeededAccountInitializer.test.ts @@ -0,0 +1,92 @@ +import { writeJson } from 'fs-extra'; +import type { JsonInteractionHandler } from '../../../src/identity/interaction/JsonInteractionHandler'; +import type { ResolveLoginHandler } from '../../../src/identity/interaction/login/ResolveLoginHandler'; +import { SeededAccountInitializer } from '../../../src/init/SeededAccountInitializer'; +import { mockFileSystem } from '../../util/Util'; + +jest.mock('fs'); +jest.mock('fs-extra'); + +describe('A SeededAccountInitializer', (): void => { + const dummyConfig = [ + { + email: 'hello@example.com', + password: 'abc123', + pods: [ + { name: 'pod1' }, + { name: 'pod2' }, + { name: 'pod3' }, + ], + }, + { + podName: 'example2', + email: 'hello2@example.com', + password: '123abc', + }, + ]; + const configFilePath = './seeded-pod-config.json'; + let accountHandler: jest.Mocked; + let passwordHandler: jest.Mocked; + let podHandler: jest.Mocked; + let initializer: SeededAccountInitializer; + + beforeEach(async(): Promise => { + let count = 0; + accountHandler = { + login: jest.fn(async(): Promise => { + count += 1; + return { json: { accountId: `account${count}` }}; + }), + } as any; + + passwordHandler = { + handleSafe: jest.fn(), + } as any; + + podHandler = { + handleSafe: jest.fn(), + } as any; + + mockFileSystem('/'); + await writeJson(configFilePath, dummyConfig); + + initializer = new SeededAccountInitializer({ + accountHandler, passwordHandler, podHandler, configFilePath, + }); + }); + + it('does not generate any accounts or pods if no config file is specified.', async(): Promise => { + await expect(new SeededAccountInitializer({ accountHandler, passwordHandler, podHandler }).handle()) + .resolves.toBeUndefined(); + expect(accountHandler.login).toHaveBeenCalledTimes(0); + }); + + it('errors if the seed file is invalid.', async(): Promise => { + await writeJson(configFilePath, 'invalid config'); + await expect(initializer.handle()).rejects + .toThrow('Invalid account seed file: this must be a `array` type, but the final value was: `"invalid config"`.'); + }); + + it('generates an account with the specified settings.', async(): Promise => { + await expect(initializer.handleSafe()).resolves.toBeUndefined(); + expect(accountHandler.login).toHaveBeenCalledTimes(2); + expect(passwordHandler.handleSafe).toHaveBeenCalledTimes(2); + expect(passwordHandler.handleSafe.mock.calls[0][0].json) + .toEqual(expect.objectContaining({ email: 'hello@example.com', password: 'abc123' })); + expect(passwordHandler.handleSafe.mock.calls[1][0].json) + .toEqual(expect.objectContaining({ email: 'hello2@example.com', password: '123abc' })); + expect(podHandler.handleSafe).toHaveBeenCalledTimes(3); + expect(podHandler.handleSafe.mock.calls[0][0].json).toEqual(expect.objectContaining(dummyConfig[0].pods![0])); + expect(podHandler.handleSafe.mock.calls[1][0].json).toEqual(expect.objectContaining(dummyConfig[0].pods![1])); + expect(podHandler.handleSafe.mock.calls[2][0].json).toEqual(expect.objectContaining(dummyConfig[0].pods![2])); + }); + + it('does not throw exceptions when one of the steps fails.', async(): Promise => { + accountHandler.login.mockRejectedValueOnce(new Error('bad data')); + await expect(initializer.handleSafe()).resolves.toBeUndefined(); + expect(accountHandler.login).toHaveBeenCalledTimes(2); + // Steps for first account will be skipped due to error + expect(passwordHandler.handleSafe).toHaveBeenCalledTimes(1); + expect(podHandler.handleSafe).toHaveBeenCalledTimes(0); + }); +}); diff --git a/test/unit/init/SeededPodInitializer.test.ts b/test/unit/init/SeededPodInitializer.test.ts deleted file mode 100644 index 142f88655..000000000 --- a/test/unit/init/SeededPodInitializer.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { writeJson } from 'fs-extra'; -import type { RegistrationManager } from '../../../src/identity/interaction/email-password/util/RegistrationManager'; -import { SeededPodInitializer } from '../../../src/init/SeededPodInitializer'; -import { mockFileSystem } from '../../util/Util'; - -jest.mock('fs'); -jest.mock('fs-extra'); - -describe('A SeededPodInitializer', (): void => { - const dummyConfig = [ - { - podName: 'example', - email: 'hello@example.com', - password: 'abc123', - }, - { - podName: 'example2', - email: 'hello2@example.com', - password: '123abc', - }, - ]; - let registrationManager: RegistrationManager; - let configFilePath: string | null; - - beforeEach(async(): Promise => { - configFilePath = './seeded-pod-config.json'; - registrationManager = { - validateInput: jest.fn((input): any => input), - register: jest.fn(), - } as any; - - mockFileSystem('/'); - await writeJson(configFilePath, dummyConfig); - }); - - it('does not generate any accounts or pods if no config file is specified.', async(): Promise => { - configFilePath = null; - await new SeededPodInitializer(registrationManager, configFilePath).handle(); - expect(registrationManager.validateInput).not.toHaveBeenCalled(); - expect(registrationManager.register).not.toHaveBeenCalled(); - }); - - it('generates an account and a pod for every entry in the seeded pod configuration.', async(): Promise => { - await new SeededPodInitializer(registrationManager, configFilePath).handle(); - expect(registrationManager.validateInput).toHaveBeenCalledTimes(2); - expect(registrationManager.register).toHaveBeenCalledTimes(2); - }); - - it('does not throw exceptions when a seeded pod already exists.', async(): Promise => { - registrationManager.register = jest.fn().mockRejectedValueOnce(new Error('Pod already exists')); - await new SeededPodInitializer(registrationManager, configFilePath).handle(); - expect(registrationManager.validateInput).toHaveBeenCalledTimes(2); - expect(registrationManager.register).toHaveBeenCalledTimes(2); - }); -}); diff --git a/test/unit/pods/ConfigPodManager.test.ts b/test/unit/pods/ConfigPodManager.test.ts index 09f582f8a..58f4d9133 100644 --- a/test/unit/pods/ConfigPodManager.test.ts +++ b/test/unit/pods/ConfigPodManager.test.ts @@ -8,7 +8,7 @@ import type { ResourceStore } from '../../../src/storage/ResourceStore'; describe('A ConfigPodManager', (): void => { let settings: PodSettings; - const base = 'http://test.com/'; + const base = 'http://example.com/'; let store: ResourceStore; let podGenerator: PodGenerator; let routingStorage: KeyValueStorage; @@ -19,9 +19,9 @@ describe('A ConfigPodManager', (): void => { beforeEach(async(): Promise => { settings = { - login: 'alice', template: 'config-template.json', webId: 'webId', + base: { path: 'http://example.com/alice/' }, }; store = { @@ -55,9 +55,9 @@ describe('A ConfigPodManager', (): void => { it('creates a pod and returns the newly generated identifier.', async(): Promise => { const identifier = { path: `${base}alice/` }; - await expect(manager.createPod(identifier, settings)).resolves.toBeUndefined(); + await expect(manager.createPod(settings)).resolves.toBeUndefined(); expect(podGenerator.generate).toHaveBeenCalledTimes(1); - expect(podGenerator.generate).toHaveBeenLastCalledWith(identifier, settings); + expect(podGenerator.generate).toHaveBeenLastCalledWith(settings); expect(resourcesGenerator.generate).toHaveBeenCalledTimes(1); expect(resourcesGenerator.generate).toHaveBeenLastCalledWith(identifier, settings); expect(initStore.setRepresentation).toHaveBeenCalledTimes(2); diff --git a/test/unit/pods/GeneratedPodManager.test.ts b/test/unit/pods/GeneratedPodManager.test.ts index 1bd241ecb..0f1133ddd 100644 --- a/test/unit/pods/GeneratedPodManager.test.ts +++ b/test/unit/pods/GeneratedPodManager.test.ts @@ -5,7 +5,7 @@ import type { ResourceStore } from '../../../src/storage/ResourceStore'; import { ConflictHttpError } from '../../../src/util/errors/ConflictHttpError'; describe('A GeneratedPodManager', (): void => { - const base = 'http://test.com/'; + const base = 'http://example.com/'; let settings: PodSettings; let store: jest.Mocked; let generatorData: Resource[]; @@ -14,9 +14,9 @@ describe('A GeneratedPodManager', (): void => { beforeEach(async(): Promise => { settings = { - login: 'user', name: 'first last', webId: 'http://secure/webId', + base: { path: 'http://example.com/user/' }, }; store = { setRepresentation: jest.fn(), @@ -37,13 +37,13 @@ describe('A GeneratedPodManager', (): void => { it('throws an error if the generate identifier is not available.', async(): Promise => { store.hasResource.mockResolvedValueOnce(true); - const result = manager.createPod({ path: `${base}user/` }, settings, false); + const result = manager.createPod(settings, false); await expect(result).rejects.toThrow(`There already is a resource at ${base}user/`); await expect(result).rejects.toThrow(ConflictHttpError); }); it('generates an identifier and writes containers before writing the resources in them.', async(): Promise => { - await expect(manager.createPod({ path: `${base}${settings.login}/` }, settings, false)).resolves.toBeUndefined(); + await expect(manager.createPod(settings, false)).resolves.toBeUndefined(); expect(store.setRepresentation).toHaveBeenCalledTimes(3); expect(store.setRepresentation).toHaveBeenNthCalledWith(1, { path: '/path/' }, '/'); @@ -53,7 +53,7 @@ describe('A GeneratedPodManager', (): void => { it('allows overwriting when enabled.', async(): Promise => { store.hasResource.mockResolvedValueOnce(true); - await expect(manager.createPod({ path: `${base}${settings.login}/` }, settings, true)).resolves.toBeUndefined(); + await expect(manager.createPod(settings, true)).resolves.toBeUndefined(); expect(store.setRepresentation).toHaveBeenCalledTimes(3); expect(store.setRepresentation).toHaveBeenNthCalledWith(1, { path: '/path/' }, '/'); diff --git a/test/unit/pods/generate/TemplatedPodGenerator.test.ts b/test/unit/pods/generate/TemplatedPodGenerator.test.ts index 069f41530..788ee6136 100644 --- a/test/unit/pods/generate/TemplatedPodGenerator.test.ts +++ b/test/unit/pods/generate/TemplatedPodGenerator.test.ts @@ -21,7 +21,11 @@ describe('A TemplatedPodGenerator', (): void => { let generator: TemplatedPodGenerator; beforeEach(async(): Promise => { - settings = { template } as any; + settings = { + base: identifier, + webId: 'http://example.com/card#me', + template, + }; storeFactory = { generate: jest.fn().mockResolvedValue('store'), @@ -38,11 +42,11 @@ describe('A TemplatedPodGenerator', (): void => { it('only supports settings with a template.', async(): Promise => { (settings as any).template = undefined; - await expect(generator.generate(identifier, settings)).rejects.toThrow(BadRequestHttpError); + await expect(generator.generate(settings)).rejects.toThrow(BadRequestHttpError); }); it('generates a store and stores relevant variables.', async(): Promise => { - await expect(generator.generate(identifier, settings)).resolves.toBe('store'); + await expect(generator.generate(settings)).resolves.toBe('store'); expect(variableHandler.handleSafe).toHaveBeenCalledTimes(1); expect(variableHandler.handleSafe).toHaveBeenLastCalledWith({ identifier, settings }); expect(storeFactory.generate).toHaveBeenCalledTimes(1); @@ -56,18 +60,18 @@ describe('A TemplatedPodGenerator', (): void => { it('rejects identifiers that already have a config.', async(): Promise => { await configStorage.set(identifier.path, {}); - await expect(generator.generate(identifier, settings)).rejects.toThrow(ConflictHttpError); + await expect(generator.generate(settings)).rejects.toThrow(ConflictHttpError); }); it('rejects invalid config template names.', async(): Promise => { settings.template = '../../secret-file.json'; - await expect(generator.generate(identifier, settings)).rejects.toThrow(BadRequestHttpError); + await expect(generator.generate(settings)).rejects.toThrow(BadRequestHttpError); }); it('only stores relevant variables from an agent object.', async(): Promise => { settings[TEMPLATE_VARIABLE.rootFilePath] = 'correctFilePath'; settings.login = 'should not be stored'; - await expect(generator.generate(identifier, settings)).resolves.toBe('store'); + await expect(generator.generate(settings)).resolves.toBe('store'); expect(configStorage.get(identifier.path)).toEqual({ [TEMPLATE_VARIABLE.templateConfig]: templatePath, [TEMPLATE_VARIABLE.rootFilePath]: 'correctFilePath', @@ -78,7 +82,7 @@ describe('A TemplatedPodGenerator', (): void => { generator = new TemplatedPodGenerator(storeFactory, variableHandler, configStorage, baseUrl); const defaultPath = joinFilePath(__dirname, '../../../../templates/config/', template); - await expect(generator.generate(identifier, settings)).resolves.toBe('store'); + await expect(generator.generate(settings)).resolves.toBe('store'); expect(storeFactory.generate) .toHaveBeenLastCalledWith(defaultPath, TEMPLATE.ResourceStore, { [TEMPLATE_VARIABLE.templateConfig]: defaultPath, diff --git a/test/unit/server/notifications/WebHookChannel2023/WebhookChannel2023Type.test.ts b/test/unit/server/notifications/WebHookChannel2023/WebhookChannel2023Type.test.ts index 26376967f..825d46504 100644 --- a/test/unit/server/notifications/WebHookChannel2023/WebhookChannel2023Type.test.ts +++ b/test/unit/server/notifications/WebHookChannel2023/WebhookChannel2023Type.test.ts @@ -37,7 +37,7 @@ describe('A WebhookChannel2023Type', (): void => { let data: Store; let channel: WebhookChannel2023; const route = new AbsolutePathInteractionRoute('http://example.com/webhooks/'); - const webIdRoute = new RelativePathInteractionRoute(route, '/webid'); + const webIdRoute = new RelativePathInteractionRoute(route, '/webid', false); let stateHandler: jest.Mocked; let channelType: WebhookChannel2023Type; diff --git a/test/unit/server/notifications/WebHookChannel2023/WebhookEmitter.test.ts b/test/unit/server/notifications/WebHookChannel2023/WebhookEmitter.test.ts index 6a0f6df3b..8ead03aec 100644 --- a/test/unit/server/notifications/WebHookChannel2023/WebhookEmitter.test.ts +++ b/test/unit/server/notifications/WebHookChannel2023/WebhookEmitter.test.ts @@ -30,7 +30,7 @@ describe('A WebhookEmitter', (): void => { const fetchMock: jest.Mock = fetch as any; const baseUrl = 'http://example.com/'; const serverWebId = 'http://example.com/.notifcations/webhooks/webid'; - const webIdRoute = new AbsolutePathInteractionRoute(serverWebId); + const webIdRoute = new AbsolutePathInteractionRoute(serverWebId, false); const notification: Notification = { '@context': [ 'https://www.w3.org/ns/activitystreams', diff --git a/test/unit/util/StringUtil.test.ts b/test/unit/util/StringUtil.test.ts index 2a7122c95..f73109448 100644 --- a/test/unit/util/StringUtil.test.ts +++ b/test/unit/util/StringUtil.test.ts @@ -1,7 +1,9 @@ import { sanitizeUrlPart, splitCommaSeparated, - isValidFileName, msToDuration, + isValidFileName, + isUrl, + msToDuration, } from '../../../src/util/StringUtil'; describe('HeaderUtil', (): void => { @@ -32,6 +34,17 @@ describe('HeaderUtil', (): void => { }); }); + describe('#isURL', (): void => { + it('returns true if the provided string is a valid URL.', (): void => { + expect(isUrl('http://localhost/foo')).toBe(true); + expect(isUrl('http://foo.localhost:3000/foo')).toBe(true); + expect(isUrl('http://example.com/foo')).toBe(true); + }); + it('returns false if the provided string is not a valid URL.', (): void => { + expect(isUrl('not valid')).toBe(false); + }); + }); + describe('#msToDuration', (): void => { it('converts ms to a duration string.', async(): Promise => { const ms = ((2 * 24 * 60 * 60) + (10 * 60 * 60) + (5 * 60) + 50.25) * 1000; diff --git a/test/util/AccountUtil.ts b/test/util/AccountUtil.ts new file mode 100644 index 000000000..be01876d3 --- /dev/null +++ b/test/util/AccountUtil.ts @@ -0,0 +1,76 @@ +import urljoin from 'url-join'; +import type { Account } from '../../src/identity/interaction/account/util/Account'; +import type { AccountStore } from '../../src/identity/interaction/account/util/AccountStore'; + +export function createAccount(id = 'id'): Account { + return { id, logins: {}, webIds: {}, pods: {}, clientCredentials: {}, settings: {}}; +} + +export function mockAccountStore(account?: Account): jest.Mocked { + return { + create: jest.fn(async(): Promise => createAccount()), + get: jest.fn().mockResolvedValue(account), + update: jest.fn(), + }; +} + +export type User = { + email: string; + password: string; + webId?: string; + podName: string; + settings?: Record; +}; + +/** + * Registers an account for the given user details and creates one or more pods. + * @param baseUrl - Base URL of the server. + * @param user - User details to register. + */ +export async function register(baseUrl: string, user: User): +Promise<{ pod: string; webId: string; authorization: string; controls: any }> { + // Get controls + let res = await fetch(urljoin(baseUrl, '.account/')); + let { controls } = await res.json(); + + // Create account + res = await fetch(controls.account.create, { method: 'POST' }); + expect(res.status).toBe(200); + const { cookie } = await res.json(); + const authorization = `CSS-Account-Cookie ${cookie}`; + + // Get account controls + res = await fetch(controls.account.create, { + headers: { authorization }, + }); + if (res.status !== 200) { + throw new Error(`Error creating account: ${await res.text()}`); + } + const json = await res.json(); + ({ controls } = json); + + // Add login method + res = await fetch(controls.password.create, { + method: 'POST', + headers: { authorization, 'content-type': 'application/json' }, + body: JSON.stringify({ + email: user.email, + password: user.password, + }), + }); + if (res.status !== 200) { + throw new Error(`Error adding login method: ${await res.text()}`); + } + + // Create pod(s) + res = await fetch(controls.account.pod, { + method: 'POST', + headers: { authorization, 'content-type': 'application/json' }, + body: JSON.stringify({ name: user.podName, settings: { webId: user.webId, ...user.settings }}), + }); + if (res.status !== 200) { + throw new Error(`Error creating pod: ${await res.text()}`); + } + + return { ...await res.json(), controls, authorization }; +} diff --git a/test/util/Util.ts b/test/util/Util.ts index 196a2d0a1..90ef1d543 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -5,6 +5,7 @@ import Describe = jest.Describe; const portNames = [ // Integration + 'Accounts', 'AcpServer', 'Conditions', 'ContentNegotiation', @@ -48,7 +49,9 @@ export function getPort(name: typeof portNames[number]): number { if (idx < 0) { throw new Error(`Unknown port name ${name}`); } - return 6000 + idx; + // 6000 is a bad port, causing node v18+ to block fetch requests targeting such a URL + // https://fetch.spec.whatwg.org/#port-blocking + return 6000 + idx + 1; } export function getSocket(name: typeof socketNames[number]): string {