From 2928c6ac2bba8799f002fca0548f165f8cfe1be0 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 2 Jul 2025 10:04:19 +0200 Subject: [PATCH] chore(login): migrate nextjs login to monorepo (#10134) # Which Problems Are Solved We move the login code to the zitadel repo. # How the Problems Are Solved The login repo is added to ./login as a git subtree pulled from the dockerize-ci branch. Apart from the login code, this PR contains the changes from #10116 # Additional Context - Closes https://github.com/zitadel/typescript/issues/474 - Also merges #10116 - Merging is blocked by failing check because of: - https://github.com/zitadel/zitadel/pull/10134#issuecomment-3012086106 --------- Co-authored-by: Max Peintner Co-authored-by: Max Peintner Co-authored-by: Florian Forster --- .github/dependabot.yml | 13 + .github/workflows/build.yml | 28 +- .github/workflows/compile.yml | 36 +- .github/workflows/login-container.yml | 63 + .github/workflows/login-quality.yml | 59 + .github/workflows/release.yml | 70 + .golangci.yaml | 1 + LICENSING.md | 7 + Makefile | 50 +- build/Dockerfile.gitignore | 3 + cmd/defaults.yaml | 10 +- docker-bake.hcl | 5 + dockerfiles/proto-files.Dockerfile | 8 + .../proto-files.Dockerfile.dockerignore | 2 + .../typescript-proto-client.Dockerfile | 8 + ...cript-proto-client.Dockerfile.dockerignore | 11 + e2e/config/host.docker.internal/zitadel.yaml | 3 + e2e/config/localhost/zitadel.yaml | 3 + internal/integration/config/zitadel.yaml | 7 + login/.changeset/README.md | 8 + login/.changeset/config.json | 11 + login/.eslintrc.cjs | 10 + login/.github/ISSUE_TEMPLATE/bug.yaml | 63 + login/.github/ISSUE_TEMPLATE/config.yml | 4 + login/.github/ISSUE_TEMPLATE/docs.yaml | 30 + login/.github/ISSUE_TEMPLATE/improvement.yaml | 54 + login/.github/ISSUE_TEMPLATE/proposal.yaml | 54 + login/.github/custom-i18n.png | Bin 0 -> 85028 bytes login/.github/dependabot.yml | 21 + login/.github/pull_request_template.md | 13 + login/.github/workflows/close_pr.yml | 35 + login/.github/workflows/issues.yml | 41 + login/.github/workflows/release.yml | 32 + login/.github/workflows/test.yml | 67 + login/.gitignore | 18 + login/.npmrc | 1 + login/.nvmrc | 1 + login/.prettierignore | 9 + login/.prettierrc | 6 + login/CODE_OF_CONDUCT.md | 128 + login/CONTRIBUTING.md | 206 + login/LICENSE | 21 + login/Makefile | 137 + login/README.md | 264 + login/acceptance/docker-compose.yaml | 71 + login/apps/login-test-acceptance/.gitignore | 1 + .../docker-compose-ci.yaml | 59 + .../login-test-acceptance/docker-compose.yaml | 237 + .../go-command.Dockerfile | 11 + .../login-test-acceptance/idp/oidc/go.mod | 28 + .../login-test-acceptance/idp/oidc/go.sum | 71 + .../login-test-acceptance/idp/oidc/main.go | 186 + .../login-test-acceptance/idp/saml/go.mod | 16 + .../login-test-acceptance/idp/saml/go.sum | 49 + .../login-test-acceptance/idp/saml/main.go | 328 + .../apps/login-test-acceptance/oidcrp/go.mod | 26 + .../apps/login-test-acceptance/oidcrp/go.sum | 67 + .../apps/login-test-acceptance/oidcrp/main.go | 322 + login/apps/login-test-acceptance/package.json | 18 + .../apps/login-test-acceptance/pat/.gitignore | 2 + login/apps/login-test-acceptance/pat/.gitkeep | 0 .../playwright-report/.gitignore | 2 + .../playwright-report/.gitkeep | 0 .../playwright.config.ts | 78 + .../apps/login-test-acceptance/samlsp/go.mod | 18 + .../apps/login-test-acceptance/samlsp/go.sum | 38 + .../apps/login-test-acceptance/samlsp/main.go | 271 + login/apps/login-test-acceptance/setup/go.mod | 3 + login/apps/login-test-acceptance/setup/go.sum | 0 .../apps/login-test-acceptance/setup/main.go | 3 + .../apps/login-test-acceptance/setup/setup.sh | 139 + login/apps/login-test-acceptance/sink/go.mod | 3 + login/apps/login-test-acceptance/sink/go.sum | 0 login/apps/login-test-acceptance/sink/main.go | 111 + .../test-results/.gitignore | 2 + .../test-results/.gitkeep | 0 .../login-test-acceptance/tests/admin.spec.ts | 7 + .../tests/code-screen.ts | 12 + .../apps/login-test-acceptance/tests/code.ts | 17 + .../tests/email-verify-screen.ts | 12 + .../tests/email-verify.spec.ts | 69 + .../tests/email-verify.ts | 15 + .../tests/idp-apple.spec.ts | 102 + .../tests/idp-generic-jwt.spec.ts | 99 + .../tests/idp-generic-oauth.spec.ts | 99 + .../tests/idp-generic-oidc.spec.ts | 101 + .../tests/idp-github-enterprise.spec.ts | 103 + .../tests/idp-github.spec.ts | 103 + .../tests/idp-gitlab-self-hosted.spec.ts | 103 + .../tests/idp-gitlab.spec.ts | 103 + .../tests/idp-google.spec.ts | 99 + .../tests/idp-ldap.spec.ts | 99 + .../tests/idp-microsoft.spec.ts | 102 + .../tests/idp-saml.spec.ts | 103 + .../login-configuration-possiblities.spec.ts | 57 + .../apps/login-test-acceptance/tests/login.ts | 41 + .../tests/loginname-screen.ts | 12 + .../login-test-acceptance/tests/loginname.ts | 7 + .../login-test-acceptance/tests/passkey.ts | 109 + .../tests/password-screen.ts | 98 + .../login-test-acceptance/tests/password.ts | 29 + .../tests/register-screen.ts | 27 + .../tests/register.spec.ts | 183 + .../login-test-acceptance/tests/register.ts | 39 + .../tests/select-account.ts | 5 + .../apps/login-test-acceptance/tests/sink.ts | 43 + .../apps/login-test-acceptance/tests/user.ts | 177 + .../tests/username-passkey.spec.ts | 43 + .../username-password-change-required.spec.ts | 41 + .../tests/username-password-changed.spec.ts | 54 + .../tests/username-password-otp_email.spec.ts | 98 + .../tests/username-password-otp_sms.spec.ts | 71 + .../tests/username-password-set.spec.ts | 52 + .../tests/username-password-totp.spec.ts | 71 + .../tests/username-password-u2f.spec.ts | 26 + .../tests/username-password.spec.ts | 157 + .../login-test-acceptance/tests/welcome.ts | 6 + .../login-test-acceptance/tests/zitadel.ts | 190 + login/apps/login-test-acceptance/turbo.json | 10 + login/apps/login-test-acceptance/zitadel.yaml | 83 + login/apps/login-test-integration/.gitignore | 2 + .../core-mock/Dockerfile | 9 + .../zitadel.settings.v2.SettingsService.json | 59 + .../core-mock/mocked-services.cfg | 7 + .../login-test-integration/cypress.config.ts | 14 + .../docker-compose.yaml | 30 + .../fixtures/example.json | 5 + .../integration/invite.cy.ts | 110 + .../integration/login.cy.ts | 172 + .../integration/register-idp.cy.ts | 21 + .../integration/register.cy.ts | 73 + .../integration/verify.cy.ts | 95 + .../apps/login-test-integration/package.json | 17 + .../login-test-integration/support/e2e.ts | 29 + .../apps/login-test-integration/tsconfig.json | 8 + login/apps/login-test-integration/turbo.json | 10 + login/apps/login/.env.test | 5 + login/apps/login/.eslintrc.cjs | 12 + login/apps/login/.gitignore | 3 + login/apps/login/.prettierignore | 2 + login/apps/login/constants/csp.js | 2 + login/apps/login/locales/de.json | 250 + login/apps/login/locales/en.json | 250 + login/apps/login/locales/es.json | 250 + login/apps/login/locales/it.json | 250 + login/apps/login/locales/pl.json | 250 + login/apps/login/locales/ru.json | 250 + login/apps/login/locales/zh.json | 250 + login/apps/login/next-env-vars.d.ts | 33 + login/apps/login/next-env.d.ts | 5 + login/apps/login/next.config.mjs | 83 + login/apps/login/package.json | 75 + login/apps/login/postcss.config.cjs | 6 + login/apps/login/prettier.config.mjs | 1 + login/apps/login/public/checkbox.svg | 1 + login/apps/login/public/favicon.ico | Bin 0 -> 15086 bytes .../public/favicon/android-chrome-192x192.png | Bin 0 -> 17828 bytes .../public/favicon/android-chrome-512x512.png | Bin 0 -> 137768 bytes .../login/public/favicon/apple-touch-icon.png | Bin 0 -> 18112 bytes .../login/public/favicon/browserconfig.xml | 9 + .../login/public/favicon/favicon-16x16.png | Bin 0 -> 1551 bytes .../login/public/favicon/favicon-32x32.png | Bin 0 -> 2050 bytes login/apps/login/public/favicon/favicon.ico | Bin 0 -> 15086 bytes .../login/public/favicon/mstile-150x150.png | Bin 0 -> 13206 bytes .../login/public/favicon/site.webmanifest | 19 + login/apps/login/public/grid-dark.svg | 5 + login/apps/login/public/grid-light.svg | 5 + .../logo/zitadel-logo-solo-darkdesign.svg | 74 + .../logo/zitadel-logo-solo-lightdesign.svg | 76 + login/apps/login/public/zitadel-logo-dark.svg | 101 + .../apps/login/public/zitadel-logo-light.svg | 99 + login/apps/login/readme.md | 394 + login/apps/login/screenshots/accounts.png | Bin 0 -> 159830 bytes .../login/screenshots/accounts_jumpto.png | Bin 0 -> 15180 bytes login/apps/login/screenshots/collage.png | Bin 0 -> 288519 bytes login/apps/login/screenshots/idp.png | Bin 0 -> 86616 bytes login/apps/login/screenshots/loginname.png | Bin 0 -> 114853 bytes login/apps/login/screenshots/mfa.png | Bin 0 -> 104053 bytes login/apps/login/screenshots/mfaset.png | Bin 0 -> 116794 bytes login/apps/login/screenshots/otp.png | Bin 0 -> 84122 bytes login/apps/login/screenshots/otpset.png | Bin 0 -> 146885 bytes login/apps/login/screenshots/passkey.png | Bin 0 -> 86883 bytes login/apps/login/screenshots/password.png | Bin 0 -> 84874 bytes .../login/screenshots/password_change.png | Bin 0 -> 123203 bytes login/apps/login/screenshots/password_set.png | Bin 0 -> 153578 bytes login/apps/login/screenshots/register.png | Bin 0 -> 161800 bytes .../login/screenshots/register_password.png | Bin 0 -> 118094 bytes login/apps/login/screenshots/signedin.png | Bin 0 -> 60794 bytes login/apps/login/screenshots/u2f.png | Bin 0 -> 76779 bytes login/apps/login/screenshots/u2fset.png | Bin 0 -> 90769 bytes login/apps/login/screenshots/verify.png | Bin 0 -> 67934 bytes .../login/src/app/(login)/accounts/page.tsx | 97 + .../app/(login)/authenticator/set/page.tsx | 218 + .../src/app/(login)/device/consent/page.tsx | 99 + .../login/src/app/(login)/device/page.tsx | 48 + login/apps/login/src/app/(login)/error.tsx | 27 + .../(login)/idp/[provider]/failure/page.tsx | 105 + .../(login)/idp/[provider]/success/page.tsx | 340 + .../login/src/app/(login)/idp/ldap/page.tsx | 56 + login/apps/login/src/app/(login)/idp/page.tsx | 51 + login/apps/login/src/app/(login)/layout.tsx | 62 + .../login/src/app/(login)/loginname/page.tsx | 93 + .../login/src/app/(login)/logout/page.tsx | 86 + .../src/app/(login)/logout/success/page.tsx | 43 + login/apps/login/src/app/(login)/mfa/page.tsx | 134 + .../login/src/app/(login)/mfa/set/page.tsx | 174 + .../src/app/(login)/otp/[method]/page.tsx | 136 + .../src/app/(login)/otp/[method]/set/page.tsx | 204 + login/apps/login/src/app/(login)/page.tsx | 8 + .../login/src/app/(login)/passkey/page.tsx | 89 + .../src/app/(login)/passkey/set/page.tsx | 85 + .../src/app/(login)/password/change/page.tsx | 100 + .../login/src/app/(login)/password/page.tsx | 102 + .../src/app/(login)/password/set/page.tsx | 137 + .../login/src/app/(login)/register/page.tsx | 136 + .../app/(login)/register/password/page.tsx | 100 + .../login/src/app/(login)/saml-post/route.ts | 30 + .../login/src/app/(login)/signedin/page.tsx | 141 + login/apps/login/src/app/(login)/u2f/page.tsx | 96 + .../login/src/app/(login)/u2f/set/page.tsx | 76 + .../login/src/app/(login)/verify/page.tsx | 174 + .../src/app/(login)/verify/success/page.tsx | 92 + login/apps/login/src/app/global-error.tsx | 36 + login/apps/login/src/app/healthy/route.ts | 5 + login/apps/login/src/app/login/route.ts | 557 + login/apps/login/src/app/security/route.ts | 28 + .../apps/login/src/components/address-bar.tsx | 61 + login/apps/login/src/components/alert.tsx | 45 + .../apps/login/src/components/app-avatar.tsx | 48 + .../login/src/components/auth-methods.tsx | 234 + .../authentication-method-radio.tsx | 104 + login/apps/login/src/components/avatar.tsx | 97 + .../apps/login/src/components/back-button.tsx | 18 + login/apps/login/src/components/boundary.tsx | 83 + login/apps/login/src/components/button.tsx | 74 + .../src/components/change-password-form.tsx | 211 + login/apps/login/src/components/checkbox.tsx | 62 + .../choose-authenticator-to-login.tsx | 38 + .../choose-authenticator-to-setup.tsx | 51 + .../choose-second-factor-to-setup.tsx | 119 + .../src/components/choose-second-factor.tsx | 54 + login/apps/login/src/components/consent.tsx | 116 + .../src/components/copy-to-clipboard.tsx | 41 + .../login/src/components/default-tags.tsx | 32 + .../login/src/components/device-code-form.tsx | 95 + .../login/src/components/dynamic-theme.tsx | 43 + .../login/src/components/external-link.tsx | 21 + .../apps/login/src/components/idp-signin.tsx | 67 + .../login/src/components/idps/base-button.tsx | 41 + .../components/idps/pages/complete-idp.tsx | 55 + .../components/idps/pages/linking-failed.tsx | 27 + .../components/idps/pages/linking-success.tsx | 30 + .../components/idps/pages/login-failed.tsx | 24 + .../components/idps/pages/login-success.tsx | 30 + .../components/idps/sign-in-with-apple.tsx | 36 + .../components/idps/sign-in-with-azure-ad.tsx | 42 + .../components/idps/sign-in-with-generic.tsx | 21 + .../components/idps/sign-in-with-github.tsx | 64 + .../idps/sign-in-with-gitlab.test.tsx | 45 + .../components/idps/sign-in-with-gitlab.tsx | 53 + .../idps/sign-in-with-google.test.tsx | 44 + .../components/idps/sign-in-with-google.tsx | 66 + login/apps/login/src/components/input.tsx | 102 + .../src/components/language-provider.tsx | 13 + .../src/components/language-switcher.tsx | 74 + .../login/src/components/layout-providers.tsx | 17 + .../ldap-username-password-form.tsx | 109 + login/apps/login/src/components/login-otp.tsx | 284 + .../login/src/components/login-passkey.tsx | 280 + login/apps/login/src/components/logo.tsx | 37 + .../components/password-complexity.test.tsx | 64 + .../src/components/password-complexity.tsx | 99 + .../login/src/components/password-form.tsx | 176 + .../components/privacy-policy-checkboxes.tsx | 105 + .../register-form-idp-incomplete.tsx | 156 + .../login/src/components/register-form.tsx | 227 + .../login/src/components/register-passkey.tsx | 220 + .../login/src/components/register-u2f.tsx | 225 + .../src/components/self-service-menu.tsx | 42 + .../src/components/session-clear-item.tsx | 105 + .../login/src/components/session-item.tsx | 156 + .../src/components/sessions-clear-list.tsx | 109 + .../login/src/components/sessions-list.tsx | 50 + .../src/components/set-password-form.tsx | 286 + .../components/set-register-password-form.tsx | 170 + .../login/src/components/sign-in-with-idp.tsx | 93 + .../login/src/components/skeleton-card.tsx | 16 + login/apps/login/src/components/skeleton.tsx | 9 + login/apps/login/src/components/spinner.tsx | 22 + .../apps/login/src/components/state-badge.tsx | 40 + login/apps/login/src/components/tab-group.tsx | 16 + login/apps/login/src/components/tab.tsx | 35 + .../login/src/components/theme-provider.tsx | 16 + .../login/src/components/theme-wrapper.tsx | 18 + login/apps/login/src/components/theme.tsx | 44 + .../login/src/components/totp-register.tsx | 157 + .../apps/login/src/components/translated.tsx | 23 + .../apps/login/src/components/user-avatar.tsx | 59 + .../login/src/components/username-form.tsx | 156 + .../apps/login/src/components/verify-form.tsx | 168 + .../src/components/zitadel-logo-dark.tsx | 210 + .../src/components/zitadel-logo-light.tsx | 210 + .../login/src/components/zitadel-logo.tsx | 32 + login/apps/login/src/helpers/base64.ts | 63 + login/apps/login/src/helpers/colors.ts | 439 + login/apps/login/src/helpers/validators.ts | 19 + login/apps/login/src/i18n/request.ts | 59 + login/apps/login/src/lib/api.ts | 17 + login/apps/login/src/lib/client.ts | 80 + login/apps/login/src/lib/cookies.ts | 341 + login/apps/login/src/lib/demos.ts | 38 + login/apps/login/src/lib/fingerprint.ts | 66 + login/apps/login/src/lib/hooks.ts | 14 + login/apps/login/src/lib/i18n.ts | 38 + login/apps/login/src/lib/idp.ts | 77 + login/apps/login/src/lib/oidc.ts | 132 + login/apps/login/src/lib/saml.ts | 130 + login/apps/login/src/lib/self.ts | 60 + login/apps/login/src/lib/server/cookie.ts | 278 + login/apps/login/src/lib/server/device.ts | 20 + login/apps/login/src/lib/server/idp.ts | 241 + login/apps/login/src/lib/server/loginname.ts | 454 + login/apps/login/src/lib/server/oidc.ts | 15 + login/apps/login/src/lib/server/otp.ts | 83 + login/apps/login/src/lib/server/passkeys.ts | 278 + login/apps/login/src/lib/server/password.ts | 460 + login/apps/login/src/lib/server/register.ts | 233 + login/apps/login/src/lib/server/session.ts | 221 + login/apps/login/src/lib/server/u2f.ts | 103 + login/apps/login/src/lib/server/verify.ts | 329 + login/apps/login/src/lib/service-url.ts | 58 + login/apps/login/src/lib/service.ts | 49 + login/apps/login/src/lib/session.ts | 194 + login/apps/login/src/lib/verify-helper.ts | 289 + login/apps/login/src/lib/zitadel.ts | 1525 +++ login/apps/login/src/middleware.ts | 109 + login/apps/login/src/styles/globals.scss | 65 + login/apps/login/src/styles/vars.scss | 174 + login/apps/login/tailwind.config.mjs | 117 + login/apps/login/tsconfig.json | 24 + login/apps/login/turbo.json | 22 + login/apps/login/vitest.config.mts | 12 + login/docker-bake.hcl | 145 + login/dockerfiles/login-client.Dockerfile | 7 + .../login-client.Dockerfile.dockerignore | 11 + login/dockerfiles/login-dev-base.Dockerfile | 3 + .../login-dev-base.Dockerfile.dockerignore | 1 + login/dockerfiles/login-lint.Dockerfile | 7 + .../login-lint.Dockerfile.dockerignore | 25 + login/dockerfiles/login-pnpm.Dockerfile | 10 + .../login-pnpm.Dockerfile.dockerignore | 6 + login/dockerfiles/login-standalone.Dockerfile | 34 + .../login-standalone.Dockerfile.dockerignore | 17 + .../login-test-acceptance.Dockerfile | 8 + ...in-test-acceptance.Dockerfile.dockerignore | 5 + .../login-test-integration.Dockerfile | 11 + ...n-test-integration.Dockerfile.dockerignore | 9 + login/dockerfiles/login-test-unit.Dockerfile | 6 + .../login-test-unit.Dockerfile.dockerignore | 13 + ...gin-typescript-proto-client-out.Dockerfile | 5 + ...t-proto-client-out.Dockerfile.dockerignore | 1 + login/dockerfiles/proto-files.Dockerfile | 8 + .../proto-files.Dockerfile.dockerignore | 1 + .../typescript-proto-client.Dockerfile | 6 + ...cript-proto-client.Dockerfile.dockerignore | 11 + login/meta.json | 4 + login/package.json | 55 + login/packages/zitadel-client/.eslintrc.cjs | 4 + login/packages/zitadel-client/.gitignore | 4 + login/packages/zitadel-client/CHANGELOG.md | 77 + login/packages/zitadel-client/README.md | 53 + login/packages/zitadel-client/package.json | 71 + login/packages/zitadel-client/src/helpers.ts | 11 + login/packages/zitadel-client/src/index.ts | 10 + .../zitadel-client/src/interceptors.test.ts | 67 + .../zitadel-client/src/interceptors.ts | 16 + login/packages/zitadel-client/src/node.ts | 36 + login/packages/zitadel-client/src/v1.ts | 11 + login/packages/zitadel-client/src/v2.ts | 27 + login/packages/zitadel-client/src/v3alpha.ts | 6 + login/packages/zitadel-client/src/web.ts | 15 + login/packages/zitadel-client/tsconfig.json | 5 + login/packages/zitadel-client/tsup.config.ts | 13 + login/packages/zitadel-client/turbo.json | 12 + .../zitadel-eslint-config/CHANGELOG.md | 13 + .../packages/zitadel-eslint-config/README.md | 35 + login/packages/zitadel-eslint-config/index.js | 13 + .../zitadel-eslint-config/package.json | 17 + .../zitadel-prettier-config/CHANGELOG.md | 13 + .../zitadel-prettier-config/README.md | 36 + .../packages/zitadel-prettier-config/index.js | 11 + .../zitadel-prettier-config/package.json | 12 + login/packages/zitadel-proto/.gitignore | 5 + login/packages/zitadel-proto/CHANGELOG.md | 47 + login/packages/zitadel-proto/README.md | 35 + login/packages/zitadel-proto/buf.gen.yaml | 10 + login/packages/zitadel-proto/package.json | 26 + login/packages/zitadel-proto/turbo.json | 9 + .../zitadel-tailwind-config/CHANGELOG.md | 13 + .../zitadel-tailwind-config/README.md | 36 + .../zitadel-tailwind-config/package.json | 12 + .../tailwind.config.mjs | 97 + login/packages/zitadel-tsconfig/CHANGELOG.md | 13 + login/packages/zitadel-tsconfig/README.md | 35 + login/packages/zitadel-tsconfig/base.json | 20 + login/packages/zitadel-tsconfig/nextjs.json | 32 + login/packages/zitadel-tsconfig/node20.json | 10 + login/packages/zitadel-tsconfig/package.json | 9 + .../zitadel-tsconfig/react-library.json | 11 + login/packages/zitadel-tsconfig/tsup.json | 5 + login/pnpm-lock.yaml | 9519 +++++++++++++++++ login/pnpm-workspace.yaml | 3 + login/scripts/entrypoint.sh | 11 + login/scripts/healthcheck.js | 14 + login/scripts/run_or_skip.sh | 67 + login/turbo.json | 51 + 416 files changed, 38969 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/login-container.yml create mode 100644 .github/workflows/login-quality.yml create mode 100644 build/Dockerfile.gitignore create mode 100644 docker-bake.hcl create mode 100644 dockerfiles/proto-files.Dockerfile create mode 100644 dockerfiles/proto-files.Dockerfile.dockerignore create mode 100644 dockerfiles/typescript-proto-client.Dockerfile create mode 100644 dockerfiles/typescript-proto-client.Dockerfile.dockerignore create mode 100644 login/.changeset/README.md create mode 100644 login/.changeset/config.json create mode 100644 login/.eslintrc.cjs create mode 100644 login/.github/ISSUE_TEMPLATE/bug.yaml create mode 100644 login/.github/ISSUE_TEMPLATE/config.yml create mode 100644 login/.github/ISSUE_TEMPLATE/docs.yaml create mode 100644 login/.github/ISSUE_TEMPLATE/improvement.yaml create mode 100644 login/.github/ISSUE_TEMPLATE/proposal.yaml create mode 100644 login/.github/custom-i18n.png create mode 100644 login/.github/dependabot.yml create mode 100644 login/.github/pull_request_template.md create mode 100644 login/.github/workflows/close_pr.yml create mode 100644 login/.github/workflows/issues.yml create mode 100644 login/.github/workflows/release.yml create mode 100644 login/.github/workflows/test.yml create mode 100644 login/.gitignore create mode 100644 login/.npmrc create mode 100644 login/.nvmrc create mode 100644 login/.prettierignore create mode 100644 login/.prettierrc create mode 100644 login/CODE_OF_CONDUCT.md create mode 100644 login/CONTRIBUTING.md create mode 100644 login/LICENSE create mode 100644 login/Makefile create mode 100644 login/README.md create mode 100644 login/acceptance/docker-compose.yaml create mode 100644 login/apps/login-test-acceptance/.gitignore create mode 100644 login/apps/login-test-acceptance/docker-compose-ci.yaml create mode 100644 login/apps/login-test-acceptance/docker-compose.yaml create mode 100644 login/apps/login-test-acceptance/go-command.Dockerfile create mode 100644 login/apps/login-test-acceptance/idp/oidc/go.mod create mode 100644 login/apps/login-test-acceptance/idp/oidc/go.sum create mode 100644 login/apps/login-test-acceptance/idp/oidc/main.go create mode 100644 login/apps/login-test-acceptance/idp/saml/go.mod create mode 100644 login/apps/login-test-acceptance/idp/saml/go.sum create mode 100644 login/apps/login-test-acceptance/idp/saml/main.go create mode 100644 login/apps/login-test-acceptance/oidcrp/go.mod create mode 100644 login/apps/login-test-acceptance/oidcrp/go.sum create mode 100644 login/apps/login-test-acceptance/oidcrp/main.go create mode 100644 login/apps/login-test-acceptance/package.json create mode 100644 login/apps/login-test-acceptance/pat/.gitignore create mode 100644 login/apps/login-test-acceptance/pat/.gitkeep create mode 100644 login/apps/login-test-acceptance/playwright-report/.gitignore create mode 100644 login/apps/login-test-acceptance/playwright-report/.gitkeep create mode 100644 login/apps/login-test-acceptance/playwright.config.ts create mode 100644 login/apps/login-test-acceptance/samlsp/go.mod create mode 100644 login/apps/login-test-acceptance/samlsp/go.sum create mode 100644 login/apps/login-test-acceptance/samlsp/main.go create mode 100644 login/apps/login-test-acceptance/setup/go.mod create mode 100644 login/apps/login-test-acceptance/setup/go.sum create mode 100644 login/apps/login-test-acceptance/setup/main.go create mode 100755 login/apps/login-test-acceptance/setup/setup.sh create mode 100644 login/apps/login-test-acceptance/sink/go.mod create mode 100644 login/apps/login-test-acceptance/sink/go.sum create mode 100644 login/apps/login-test-acceptance/sink/main.go create mode 100644 login/apps/login-test-acceptance/test-results/.gitignore create mode 100644 login/apps/login-test-acceptance/test-results/.gitkeep create mode 100644 login/apps/login-test-acceptance/tests/admin.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/code-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/code.ts create mode 100644 login/apps/login-test-acceptance/tests/email-verify-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/email-verify.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/email-verify.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-apple.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-github.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-google.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-ldap.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-saml.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/login.ts create mode 100644 login/apps/login-test-acceptance/tests/loginname-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/loginname.ts create mode 100644 login/apps/login-test-acceptance/tests/passkey.ts create mode 100644 login/apps/login-test-acceptance/tests/password-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/password.ts create mode 100644 login/apps/login-test-acceptance/tests/register-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/register.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/register.ts create mode 100644 login/apps/login-test-acceptance/tests/select-account.ts create mode 100644 login/apps/login-test-acceptance/tests/sink.ts create mode 100644 login/apps/login-test-acceptance/tests/user.ts create mode 100644 login/apps/login-test-acceptance/tests/username-passkey.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-changed.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-set.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-totp.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/welcome.ts create mode 100644 login/apps/login-test-acceptance/tests/zitadel.ts create mode 100644 login/apps/login-test-acceptance/turbo.json create mode 100644 login/apps/login-test-acceptance/zitadel.yaml create mode 100644 login/apps/login-test-integration/.gitignore create mode 100644 login/apps/login-test-integration/core-mock/Dockerfile create mode 100644 login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json create mode 100644 login/apps/login-test-integration/core-mock/mocked-services.cfg create mode 100644 login/apps/login-test-integration/cypress.config.ts create mode 100644 login/apps/login-test-integration/docker-compose.yaml create mode 100644 login/apps/login-test-integration/fixtures/example.json create mode 100644 login/apps/login-test-integration/integration/invite.cy.ts create mode 100644 login/apps/login-test-integration/integration/login.cy.ts create mode 100644 login/apps/login-test-integration/integration/register-idp.cy.ts create mode 100644 login/apps/login-test-integration/integration/register.cy.ts create mode 100644 login/apps/login-test-integration/integration/verify.cy.ts create mode 100644 login/apps/login-test-integration/package.json create mode 100644 login/apps/login-test-integration/support/e2e.ts create mode 100644 login/apps/login-test-integration/tsconfig.json create mode 100644 login/apps/login-test-integration/turbo.json create mode 100644 login/apps/login/.env.test create mode 100755 login/apps/login/.eslintrc.cjs create mode 100644 login/apps/login/.gitignore create mode 100644 login/apps/login/.prettierignore create mode 100644 login/apps/login/constants/csp.js create mode 100644 login/apps/login/locales/de.json create mode 100644 login/apps/login/locales/en.json create mode 100644 login/apps/login/locales/es.json create mode 100644 login/apps/login/locales/it.json create mode 100644 login/apps/login/locales/pl.json create mode 100644 login/apps/login/locales/ru.json create mode 100644 login/apps/login/locales/zh.json create mode 100644 login/apps/login/next-env-vars.d.ts create mode 100755 login/apps/login/next-env.d.ts create mode 100755 login/apps/login/next.config.mjs create mode 100644 login/apps/login/package.json create mode 100644 login/apps/login/postcss.config.cjs create mode 100644 login/apps/login/prettier.config.mjs create mode 100644 login/apps/login/public/checkbox.svg create mode 100644 login/apps/login/public/favicon.ico create mode 100644 login/apps/login/public/favicon/android-chrome-192x192.png create mode 100644 login/apps/login/public/favicon/android-chrome-512x512.png create mode 100644 login/apps/login/public/favicon/apple-touch-icon.png create mode 100644 login/apps/login/public/favicon/browserconfig.xml create mode 100644 login/apps/login/public/favicon/favicon-16x16.png create mode 100644 login/apps/login/public/favicon/favicon-32x32.png create mode 100644 login/apps/login/public/favicon/favicon.ico create mode 100644 login/apps/login/public/favicon/mstile-150x150.png create mode 100644 login/apps/login/public/favicon/site.webmanifest create mode 100644 login/apps/login/public/grid-dark.svg create mode 100644 login/apps/login/public/grid-light.svg create mode 100644 login/apps/login/public/logo/zitadel-logo-solo-darkdesign.svg create mode 100644 login/apps/login/public/logo/zitadel-logo-solo-lightdesign.svg create mode 100644 login/apps/login/public/zitadel-logo-dark.svg create mode 100644 login/apps/login/public/zitadel-logo-light.svg create mode 100644 login/apps/login/readme.md create mode 100644 login/apps/login/screenshots/accounts.png create mode 100644 login/apps/login/screenshots/accounts_jumpto.png create mode 100644 login/apps/login/screenshots/collage.png create mode 100644 login/apps/login/screenshots/idp.png create mode 100644 login/apps/login/screenshots/loginname.png create mode 100644 login/apps/login/screenshots/mfa.png create mode 100644 login/apps/login/screenshots/mfaset.png create mode 100644 login/apps/login/screenshots/otp.png create mode 100644 login/apps/login/screenshots/otpset.png create mode 100644 login/apps/login/screenshots/passkey.png create mode 100644 login/apps/login/screenshots/password.png create mode 100644 login/apps/login/screenshots/password_change.png create mode 100644 login/apps/login/screenshots/password_set.png create mode 100644 login/apps/login/screenshots/register.png create mode 100644 login/apps/login/screenshots/register_password.png create mode 100644 login/apps/login/screenshots/signedin.png create mode 100644 login/apps/login/screenshots/u2f.png create mode 100644 login/apps/login/screenshots/u2fset.png create mode 100644 login/apps/login/screenshots/verify.png create mode 100644 login/apps/login/src/app/(login)/accounts/page.tsx create mode 100644 login/apps/login/src/app/(login)/authenticator/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/device/consent/page.tsx create mode 100644 login/apps/login/src/app/(login)/device/page.tsx create mode 100644 login/apps/login/src/app/(login)/error.tsx create mode 100644 login/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx create mode 100644 login/apps/login/src/app/(login)/idp/[provider]/success/page.tsx create mode 100644 login/apps/login/src/app/(login)/idp/ldap/page.tsx create mode 100644 login/apps/login/src/app/(login)/idp/page.tsx create mode 100644 login/apps/login/src/app/(login)/layout.tsx create mode 100644 login/apps/login/src/app/(login)/loginname/page.tsx create mode 100644 login/apps/login/src/app/(login)/logout/page.tsx create mode 100644 login/apps/login/src/app/(login)/logout/success/page.tsx create mode 100644 login/apps/login/src/app/(login)/mfa/page.tsx create mode 100644 login/apps/login/src/app/(login)/mfa/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/otp/[method]/page.tsx create mode 100644 login/apps/login/src/app/(login)/otp/[method]/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/page.tsx create mode 100644 login/apps/login/src/app/(login)/passkey/page.tsx create mode 100644 login/apps/login/src/app/(login)/passkey/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/password/change/page.tsx create mode 100644 login/apps/login/src/app/(login)/password/page.tsx create mode 100644 login/apps/login/src/app/(login)/password/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/register/page.tsx create mode 100644 login/apps/login/src/app/(login)/register/password/page.tsx create mode 100644 login/apps/login/src/app/(login)/saml-post/route.ts create mode 100644 login/apps/login/src/app/(login)/signedin/page.tsx create mode 100644 login/apps/login/src/app/(login)/u2f/page.tsx create mode 100644 login/apps/login/src/app/(login)/u2f/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/verify/page.tsx create mode 100644 login/apps/login/src/app/(login)/verify/success/page.tsx create mode 100644 login/apps/login/src/app/global-error.tsx create mode 100644 login/apps/login/src/app/healthy/route.ts create mode 100644 login/apps/login/src/app/login/route.ts create mode 100644 login/apps/login/src/app/security/route.ts create mode 100644 login/apps/login/src/components/address-bar.tsx create mode 100644 login/apps/login/src/components/alert.tsx create mode 100644 login/apps/login/src/components/app-avatar.tsx create mode 100644 login/apps/login/src/components/auth-methods.tsx create mode 100644 login/apps/login/src/components/authentication-method-radio.tsx create mode 100644 login/apps/login/src/components/avatar.tsx create mode 100644 login/apps/login/src/components/back-button.tsx create mode 100644 login/apps/login/src/components/boundary.tsx create mode 100644 login/apps/login/src/components/button.tsx create mode 100644 login/apps/login/src/components/change-password-form.tsx create mode 100644 login/apps/login/src/components/checkbox.tsx create mode 100644 login/apps/login/src/components/choose-authenticator-to-login.tsx create mode 100644 login/apps/login/src/components/choose-authenticator-to-setup.tsx create mode 100644 login/apps/login/src/components/choose-second-factor-to-setup.tsx create mode 100644 login/apps/login/src/components/choose-second-factor.tsx create mode 100644 login/apps/login/src/components/consent.tsx create mode 100644 login/apps/login/src/components/copy-to-clipboard.tsx create mode 100644 login/apps/login/src/components/default-tags.tsx create mode 100644 login/apps/login/src/components/device-code-form.tsx create mode 100644 login/apps/login/src/components/dynamic-theme.tsx create mode 100644 login/apps/login/src/components/external-link.tsx create mode 100644 login/apps/login/src/components/idp-signin.tsx create mode 100644 login/apps/login/src/components/idps/base-button.tsx create mode 100644 login/apps/login/src/components/idps/pages/complete-idp.tsx create mode 100644 login/apps/login/src/components/idps/pages/linking-failed.tsx create mode 100644 login/apps/login/src/components/idps/pages/linking-success.tsx create mode 100644 login/apps/login/src/components/idps/pages/login-failed.tsx create mode 100644 login/apps/login/src/components/idps/pages/login-success.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-apple.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-azure-ad.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-generic.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-github.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-gitlab.test.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-gitlab.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-google.test.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-google.tsx create mode 100644 login/apps/login/src/components/input.tsx create mode 100644 login/apps/login/src/components/language-provider.tsx create mode 100644 login/apps/login/src/components/language-switcher.tsx create mode 100644 login/apps/login/src/components/layout-providers.tsx create mode 100644 login/apps/login/src/components/ldap-username-password-form.tsx create mode 100644 login/apps/login/src/components/login-otp.tsx create mode 100644 login/apps/login/src/components/login-passkey.tsx create mode 100644 login/apps/login/src/components/logo.tsx create mode 100644 login/apps/login/src/components/password-complexity.test.tsx create mode 100644 login/apps/login/src/components/password-complexity.tsx create mode 100644 login/apps/login/src/components/password-form.tsx create mode 100644 login/apps/login/src/components/privacy-policy-checkboxes.tsx create mode 100644 login/apps/login/src/components/register-form-idp-incomplete.tsx create mode 100644 login/apps/login/src/components/register-form.tsx create mode 100644 login/apps/login/src/components/register-passkey.tsx create mode 100644 login/apps/login/src/components/register-u2f.tsx create mode 100644 login/apps/login/src/components/self-service-menu.tsx create mode 100644 login/apps/login/src/components/session-clear-item.tsx create mode 100644 login/apps/login/src/components/session-item.tsx create mode 100644 login/apps/login/src/components/sessions-clear-list.tsx create mode 100644 login/apps/login/src/components/sessions-list.tsx create mode 100644 login/apps/login/src/components/set-password-form.tsx create mode 100644 login/apps/login/src/components/set-register-password-form.tsx create mode 100644 login/apps/login/src/components/sign-in-with-idp.tsx create mode 100644 login/apps/login/src/components/skeleton-card.tsx create mode 100644 login/apps/login/src/components/skeleton.tsx create mode 100644 login/apps/login/src/components/spinner.tsx create mode 100644 login/apps/login/src/components/state-badge.tsx create mode 100644 login/apps/login/src/components/tab-group.tsx create mode 100644 login/apps/login/src/components/tab.tsx create mode 100644 login/apps/login/src/components/theme-provider.tsx create mode 100644 login/apps/login/src/components/theme-wrapper.tsx create mode 100644 login/apps/login/src/components/theme.tsx create mode 100644 login/apps/login/src/components/totp-register.tsx create mode 100644 login/apps/login/src/components/translated.tsx create mode 100644 login/apps/login/src/components/user-avatar.tsx create mode 100644 login/apps/login/src/components/username-form.tsx create mode 100644 login/apps/login/src/components/verify-form.tsx create mode 100644 login/apps/login/src/components/zitadel-logo-dark.tsx create mode 100644 login/apps/login/src/components/zitadel-logo-light.tsx create mode 100644 login/apps/login/src/components/zitadel-logo.tsx create mode 100644 login/apps/login/src/helpers/base64.ts create mode 100644 login/apps/login/src/helpers/colors.ts create mode 100644 login/apps/login/src/helpers/validators.ts create mode 100644 login/apps/login/src/i18n/request.ts create mode 100644 login/apps/login/src/lib/api.ts create mode 100644 login/apps/login/src/lib/client.ts create mode 100644 login/apps/login/src/lib/cookies.ts create mode 100644 login/apps/login/src/lib/demos.ts create mode 100644 login/apps/login/src/lib/fingerprint.ts create mode 100644 login/apps/login/src/lib/hooks.ts create mode 100644 login/apps/login/src/lib/i18n.ts create mode 100644 login/apps/login/src/lib/idp.ts create mode 100644 login/apps/login/src/lib/oidc.ts create mode 100644 login/apps/login/src/lib/saml.ts create mode 100644 login/apps/login/src/lib/self.ts create mode 100644 login/apps/login/src/lib/server/cookie.ts create mode 100644 login/apps/login/src/lib/server/device.ts create mode 100644 login/apps/login/src/lib/server/idp.ts create mode 100644 login/apps/login/src/lib/server/loginname.ts create mode 100644 login/apps/login/src/lib/server/oidc.ts create mode 100644 login/apps/login/src/lib/server/otp.ts create mode 100644 login/apps/login/src/lib/server/passkeys.ts create mode 100644 login/apps/login/src/lib/server/password.ts create mode 100644 login/apps/login/src/lib/server/register.ts create mode 100644 login/apps/login/src/lib/server/session.ts create mode 100644 login/apps/login/src/lib/server/u2f.ts create mode 100644 login/apps/login/src/lib/server/verify.ts create mode 100644 login/apps/login/src/lib/service-url.ts create mode 100644 login/apps/login/src/lib/service.ts create mode 100644 login/apps/login/src/lib/session.ts create mode 100644 login/apps/login/src/lib/verify-helper.ts create mode 100644 login/apps/login/src/lib/zitadel.ts create mode 100644 login/apps/login/src/middleware.ts create mode 100755 login/apps/login/src/styles/globals.scss create mode 100644 login/apps/login/src/styles/vars.scss create mode 100644 login/apps/login/tailwind.config.mjs create mode 100755 login/apps/login/tsconfig.json create mode 100644 login/apps/login/turbo.json create mode 100644 login/apps/login/vitest.config.mts create mode 100644 login/docker-bake.hcl create mode 100644 login/dockerfiles/login-client.Dockerfile create mode 100644 login/dockerfiles/login-client.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-dev-base.Dockerfile create mode 100644 login/dockerfiles/login-dev-base.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-lint.Dockerfile create mode 100644 login/dockerfiles/login-lint.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-pnpm.Dockerfile create mode 100644 login/dockerfiles/login-pnpm.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-standalone.Dockerfile create mode 100644 login/dockerfiles/login-standalone.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-test-acceptance.Dockerfile create mode 100644 login/dockerfiles/login-test-acceptance.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-test-integration.Dockerfile create mode 100644 login/dockerfiles/login-test-integration.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-test-unit.Dockerfile create mode 100644 login/dockerfiles/login-test-unit.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-typescript-proto-client-out.Dockerfile create mode 100644 login/dockerfiles/login-typescript-proto-client-out.Dockerfile.dockerignore create mode 100644 login/dockerfiles/proto-files.Dockerfile create mode 100644 login/dockerfiles/proto-files.Dockerfile.dockerignore create mode 100644 login/dockerfiles/typescript-proto-client.Dockerfile create mode 100644 login/dockerfiles/typescript-proto-client.Dockerfile.dockerignore create mode 100644 login/meta.json create mode 100644 login/package.json create mode 100644 login/packages/zitadel-client/.eslintrc.cjs create mode 100644 login/packages/zitadel-client/.gitignore create mode 100644 login/packages/zitadel-client/CHANGELOG.md create mode 100644 login/packages/zitadel-client/README.md create mode 100644 login/packages/zitadel-client/package.json create mode 100644 login/packages/zitadel-client/src/helpers.ts create mode 100644 login/packages/zitadel-client/src/index.ts create mode 100644 login/packages/zitadel-client/src/interceptors.test.ts create mode 100644 login/packages/zitadel-client/src/interceptors.ts create mode 100644 login/packages/zitadel-client/src/node.ts create mode 100644 login/packages/zitadel-client/src/v1.ts create mode 100644 login/packages/zitadel-client/src/v2.ts create mode 100644 login/packages/zitadel-client/src/v3alpha.ts create mode 100644 login/packages/zitadel-client/src/web.ts create mode 100644 login/packages/zitadel-client/tsconfig.json create mode 100644 login/packages/zitadel-client/tsup.config.ts create mode 100644 login/packages/zitadel-client/turbo.json create mode 100644 login/packages/zitadel-eslint-config/CHANGELOG.md create mode 100644 login/packages/zitadel-eslint-config/README.md create mode 100644 login/packages/zitadel-eslint-config/index.js create mode 100644 login/packages/zitadel-eslint-config/package.json create mode 100644 login/packages/zitadel-prettier-config/CHANGELOG.md create mode 100644 login/packages/zitadel-prettier-config/README.md create mode 100644 login/packages/zitadel-prettier-config/index.js create mode 100644 login/packages/zitadel-prettier-config/package.json create mode 100644 login/packages/zitadel-proto/.gitignore create mode 100644 login/packages/zitadel-proto/CHANGELOG.md create mode 100644 login/packages/zitadel-proto/README.md create mode 100644 login/packages/zitadel-proto/buf.gen.yaml create mode 100644 login/packages/zitadel-proto/package.json create mode 100644 login/packages/zitadel-proto/turbo.json create mode 100644 login/packages/zitadel-tailwind-config/CHANGELOG.md create mode 100644 login/packages/zitadel-tailwind-config/README.md create mode 100644 login/packages/zitadel-tailwind-config/package.json create mode 100644 login/packages/zitadel-tailwind-config/tailwind.config.mjs create mode 100644 login/packages/zitadel-tsconfig/CHANGELOG.md create mode 100644 login/packages/zitadel-tsconfig/README.md create mode 100644 login/packages/zitadel-tsconfig/base.json create mode 100644 login/packages/zitadel-tsconfig/nextjs.json create mode 100644 login/packages/zitadel-tsconfig/node20.json create mode 100644 login/packages/zitadel-tsconfig/package.json create mode 100644 login/packages/zitadel-tsconfig/react-library.json create mode 100644 login/packages/zitadel-tsconfig/tsup.json create mode 100644 login/pnpm-lock.yaml create mode 100644 login/pnpm-workspace.yaml create mode 100755 login/scripts/entrypoint.sh create mode 100644 login/scripts/healthcheck.js create mode 100755 login/scripts/run_or_skip.sh create mode 100644 login/turbo.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b7354f3f4a..8fa71ba652 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,6 +22,19 @@ updates: commit-message: prefix: chore include: scope +- package-ecosystem: npm + directory: '/login' + open-pull-requests-limit: 3 + schedule: + interval: daily + groups: + prod: + dependency-type: production + dev: + dependency-type: development + ignore: + - dependency-name: "eslint" + versions: [ "9.x" ] - package-ecosystem: gomod groups: go: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f06c4a959c..47aa4adef0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,6 +18,8 @@ permissions: packages: write issues: write pull-requests: write + actions: write + id-token: write jobs: core: @@ -47,6 +49,7 @@ jobs: core_cache_path: ${{ needs.core.outputs.cache_path }} console_cache_path: ${{ needs.console.outputs.cache_path }} version: ${{ needs.version.outputs.version }} + node_version: "20" core-unit-test: needs: core @@ -76,6 +79,16 @@ jobs: core_cache_key: ${{ needs.core.outputs.cache_key }} core_cache_path: ${{ needs.core.outputs.cache_path }} + login-quality: + needs: [compile] + uses: ./.github/workflows/login-quality.yml + permissions: + actions: write + id-token: write + with: + ignore-run-cache: ${{ github.event_name == 'workflow_dispatch' }} + node_version: "20" + container: needs: [compile] uses: ./.github/workflows/container.yml @@ -86,6 +99,16 @@ jobs: with: build_image_name: "ghcr.io/zitadel/zitadel-build" + login-container: + uses: ./.github/workflows/login-container.yml + if: ${{ github.event_name == 'workflow_dispatch' }} + permissions: + packages: write + id-token: write + with: + login_build_image_name: "ghcr.io/zitadel/login-build" + node_version: "20" + e2e: uses: ./.github/workflows/e2e.yml needs: [compile] @@ -98,7 +121,7 @@ jobs: issues: write pull-requests: write needs: - [version, core-unit-test, core-integration-test, lint, container, e2e] + [version, core-unit-test, core-integration-test, lint, container, login-container, login-quality, e2e] if: ${{ github.event_name == 'workflow_dispatch' }} secrets: GCR_JSON_KEY_BASE64: ${{ secrets.GCR_JSON_KEY_BASE64 }} @@ -109,3 +132,6 @@ jobs: semantic_version: "23.0.7" image_name: "ghcr.io/zitadel/zitadel" google_image_name: "europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel" + build_image_name_login: ${{ needs.login-container.outputs.login_build_image }} + image_name_login: "ghcr.io/zitadel/login" + google_image_name_login: europe-docker.pkg.dev/zitadel-common/zitadel-repo/login diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index 519586b9ee..7b64427a18 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -18,7 +18,9 @@ on: version: required: true type: string - + node_version: + required: true + type: string jobs: executable: runs-on: ubuntu-latest @@ -73,10 +75,38 @@ jobs: with: name: zitadel-${{ matrix.goos }}-${{ matrix.goarch }} path: zitadel-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz - + + login: + runs-on: ubuntu-latest + steps: + - + uses: actions/checkout@v4 + - + uses: depot/setup-action@v1 + with: + oidc: true + - + run: make login_standalone_out + env: + # latest if branch is main, otherwise image version which is the pull request number + LOGIN_BAKE_CLI: depot bake + DEPOT_PROJECT_ID: w47wkxzdtw + NODE_VERSION: ${{ inputs.node_version }} + - + name: move files + run: | + cp login/LICENSE login/apps/login/standalone/ + cp login/README.md login/apps/login/standalone/ + tar -czvf login.tar.gz -C login/apps/login/standalone . + - + uses: actions/upload-artifact@v4 + with: + name: login + path: login.tar.gz + checksums: runs-on: ubuntu-latest - needs: executable + needs: [executable, login] steps: - uses: actions/download-artifact@v4 diff --git a/.github/workflows/login-container.yml b/.github/workflows/login-container.yml new file mode 100644 index 0000000000..bce15512af --- /dev/null +++ b/.github/workflows/login-container.yml @@ -0,0 +1,63 @@ +name: Login Container + +on: + workflow_call: + inputs: + login_build_image_name: + description: 'The image repository name of the standalone login image' + type: string + required: true + node_version: + required: true + type: string + outputs: + login_build_image: + description: 'The full image tag of the standalone login image' + value: '${{ inputs.login_build_image_name }}:${{ github.sha }}' + +permissions: + packages: write + +env: + default_labels: | + org.opencontainers.image.documentation=https://zitadel.com/docs + org.opencontainers.image.vendor=CAOS AG + +jobs: + login-container: + name: Build Login Container + runs-on: depot-ubuntu-22.04-8 + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: depot/setup-action@v1 + with: + oidc: true + - name: Login meta + id: login-meta + uses: docker/metadata-action@v5 + with: + images: ${{ inputs.login_build_image_name }} + labels: ${{ env.default_labels}} + tags: | + type=sha,prefix=,suffix=,format=long + - name: Login to Docker registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Bake login multi-arch + uses: depot/bake-action@v1 + env: + NODE_VERSION: ${{ inputs.node_version }} + with: + workdir: login + push: true + targets: login-standalone + set: login-standalone.platforms=[linux/amd64,linux/arm64] + project: w47wkxzdtw + files: | + ./docker-bake.hcl + cwd://${{ steps.login-meta.outputs.bake-file }} diff --git a/.github/workflows/login-quality.yml b/.github/workflows/login-quality.yml new file mode 100644 index 0000000000..0b4fea73f4 --- /dev/null +++ b/.github/workflows/login-quality.yml @@ -0,0 +1,59 @@ +name: Login Quality + +on: + workflow_call: + inputs: + ignore-run-cache: + description: 'Ignore run caches' + type: boolean + required: true + node_version: + required: true + type: string +jobs: + quality: + name: Ensure Quality + runs-on: depot-ubuntu-22.04-8 + timeout-minutes: 30 + permissions: + id-token: write + actions: write + env: + CACHE_DIR: /tmp/login-run-caches + steps: + - uses: actions/checkout@v4 + - uses: depot/setup-action@v1 + with: + oidc: true + - name: Restore Run Caches + uses: actions/cache/restore@v4 + id: run-caches-restore + with: + path: ${{ env.CACHE_DIR }} + key: ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}-${{github.run_attempt}} + restore-keys: | + ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}- + ${{ runner.os }}-login-run-caches-${{github.ref_name}}- + ${{ runner.os }}-login-run-caches- + - uses: actions/download-artifact@v4 + with: + path: .artifacts + name: zitadel-linux-amd64 + - name: Unpack executable + run: | + tar -xvf .artifacts/zitadel-linux-amd64.tar.gz + mv zitadel-linux-amd64/zitadel ./zitadel + - run: make login_quality + env: + # latest if branch is main, otherwise image version which is the pull request number + LOGIN_BAKE_CLI: depot bake + DEPOT_PROJECT_ID: w47wkxzdtw + IGNORE_RUN_CACHE: ${{ github.event.inputs.ignore-run-cache }} + NODE_VERSION: ${{ inputs.node_version }} + + - name: Save Run Caches + uses: actions/cache/save@v4 + with: + path: ${{ env.CACHE_DIR }} + key: ${{ steps.run-caches-restore.outputs.cache-primary-key }} + if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e40ae8805..e23c8869c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,15 @@ on: google_image_name: required: true type: string + build_image_name_login: + required: true + type: string + image_name_login: + required: true + type: string + google_image_name_login: + required: true + type: string secrets: GCR_JSON_KEY_BASE64: description: 'base64 endcrypted key to connect to Google' @@ -96,6 +105,12 @@ jobs: docker buildx imagetools create \ --tag ${{ inputs.google_image_name }}:${{ needs.version.outputs.version }} \ ${{ inputs.build_image_name }} + docker buildx imagetools create \ + --tag ${{ inputs.image_name_login }}:${{ needs.version.outputs.version }} \ + ${{ inputs.build_image_name_login }} + docker buildx imagetools create \ + --tag ${{ inputs.google_image_name_login }}:${{ needs.version.outputs.version }} \ + ${{ inputs.build_image_name_login }} - name: Publish latest if: ${{ github.ref_name == 'next' }} @@ -106,6 +121,9 @@ jobs: docker buildx imagetools create \ --tag ${{ inputs.image_name }}:latest-debug \ ${{ inputs.build_image_name }}-debug + docker buildx imagetools create \ + --tag ${{ inputs.image_name_login }}:latest \ + ${{ inputs.build_image_name_login }} homebrew-tap: runs-on: ubuntu-22.04 @@ -146,3 +164,55 @@ jobs: GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | gh workflow -R zitadel/zitadel-charts run bump.yml + + typescript-packages: + runs-on: ubuntu-latest + needs: version + if: ${{ github.ref_name == 'next' }} + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + working-directory: login + run: pnpm install + + - name: Create Release Pull Request + uses: changesets/action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + version: ${{ needs.version.outputs.version }} + cwd: login + + typescript-repo: + runs-on: ubuntu-latest + needs: version + if: ${{ github.ref_name == 'next' }} + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Push Subtree + run: make login_push LOGIN_REMOTE_BRANCH=mirror-zitadel-repo + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: mirror zitadel repo' + branch: mirror-zitadel-repo + title: 'chore: mirror zitadel repo' + body: 'This PR updates the login repository with the latest changes from the zitadel repository.' + base: main + reviewers: | + @peintnermax + @eliobischof diff --git a/.golangci.yaml b/.golangci.yaml index 1cae359605..a4d5fd95d4 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -20,6 +20,7 @@ issues: - openapi - proto - tools + - login run: concurrency: 4 diff --git a/LICENSING.md b/LICENSING.md index 9cad2082f8..259a0d5070 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -18,6 +18,13 @@ The following files and directories, including their subdirectories, are license proto/ ``` + +The following files and directories, including their subdirectories, are licensed under the [MIT License](https://opensource.org/license/mit/): + +``` +login/ +``` + ## Community Contributions To maintain a clear licensing structure and facilitate community contributions, all contributions must be licensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) to be accepted. By submitting a contribution, you agree to this licensing. diff --git a/Makefile b/Makefile index 3c50231bee..10f52b7c4c 100644 --- a/Makefile +++ b/Makefile @@ -12,11 +12,21 @@ ZITADEL_MASTERKEY ?= MasterkeyNeedsToHave32Characters export GOCOVERDIR ZITADEL_MASTERKEY +LOGIN_REMOTE_NAME := login +LOGIN_REMOTE_URL ?= https://github.com/zitadel/typescript.git +LOGIN_REMOTE_BRANCH ?= main + .PHONY: compile compile: core_build console_build compile_pipeline .PHONY: docker_image -docker_image: compile +docker_image: + @if [ ! -f ./zitadel ]; then \ + echo "Compiling zitadel binary"; \ + $(MAKE) compile; \ + else \ + echo "Reusing precompiled zitadel binary"; \ + fi DOCKER_BUILDKIT=1 docker build -f build/Dockerfile -t $(ZITADEL_IMAGE) . .PHONY: compile_pipeline @@ -165,3 +175,41 @@ core_lint: --config ./.golangci.yaml \ --out-format=github-actions \ --concurrency=$$(getconf _NPROCESSORS_ONLN) + +.PHONY: login_pull +login_pull: login_ensure_remote + @echo "Pulling changes from the 'login' subtree on remote $(LOGIN_REMOTE_NAME) branch $(LOGIN_REMOTE_BRANCH)" + git fetch $(LOGIN_REMOTE_NAME) + git subtree pull --prefix=login $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_BRANCH) + +.PHONY: login_push +login_push: login_ensure_remote + @echo "Pushing changes to the 'login' subtree on remote $(LOGIN_REMOTE_NAME) branch $(LOGIN_REMOTE_BRANCH)" + git subtree push --prefix=login $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_BRANCH) + +login_ensure_remote: + @if ! git remote get-url $(LOGIN_REMOTE_NAME) > /dev/null 2>&1; then \ + echo "Adding remote $(LOGIN_REMOTE_NAME)"; \ + git remote add $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_URL); \ + else \ + echo "Remote $(LOGIN_REMOTE_NAME) already exists."; \ + fi + @if [ ! -d login ]; then \ + echo "Adding subtree for 'login' from branch $(LOGIN_REMOTE_BRANCH)"; \ + git subtree add --prefix=login $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_BRANCH); \ + else \ + echo "Subtree 'login' already exists."; \ + fi + +export LOGIN_DIR := ./login/ +export LOGIN_BAKE_CLI_ADDITIONAL_ARGS := --set login-*.context=./login/ --file ./docker-bake.hcl +export ZITADEL_TAG ?= $(ZITADEL_IMAGE) +include login/Makefile + +# Intentional override of login_test_acceptance_build +login_test_acceptance_build: docker_image + @echo "Building login test acceptance environment with the local zitadel image" + $(MAKE) login_test_acceptance_build_compose login_test_acceptance_build_bake + +login_dev: docker_image typescript_generate login_test_acceptance_build_compose login_test_acceptance_cleanup login_test_acceptance_setup_dev + @echo "Starting login test environment with the local zitadel image" diff --git a/build/Dockerfile.gitignore b/build/Dockerfile.gitignore new file mode 100644 index 0000000000..a2cc8ed480 --- /dev/null +++ b/build/Dockerfile.gitignore @@ -0,0 +1,3 @@ +* +!build/entrypoint.sh +!zitadel diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 7bb44b743f..9697e354c5 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -526,13 +526,13 @@ OIDC: CharSet: "BCDFGHJKLMNPQRSTVWXZ" # ZITADEL_OIDC_DEVICEAUTH_USERCODE_CHARSET CharAmount: 8 # ZITADEL_OIDC_DEVICEAUTH_USERCODE_CHARARMOUNT DashInterval: 4 # ZITADEL_OIDC_DEVICEAUTH_USERCODE_DASHINTERVAL - DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 - DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 + DefaultLogoutURLV2: "/ui/v2/login/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 PublicKeyCacheMaxAge: 24h # ZITADEL_OIDC_PUBLICKEYCACHEMAXAGE DefaultBackChannelLogoutLifetime: 15m # ZITADEL_OIDC_DEFAULTBACKCHANNELLOGOUTLIFETIME SAML: - DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 + DefaultLoginURLV2: "/ui/v2/login/login?samlRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 ProviderConfig: MetadataConfig: Path: "/metadata" # ZITADEL_SAML_PROVIDERCONFIG_METADATACONFIG_PATH @@ -1131,8 +1131,8 @@ DefaultInstance: # OIDCSingleV1SessionTermination: false # ZITADEL_DEFAULTINSTANCE_FEATURES_OIDCSINGLEV1SESSIONTERMINATION # DisableUserTokenEvent: false # ZITADEL_DEFAULTINSTANCE_FEATURES_DISABLEUSERTOKENEVENT # EnableBackChannelLogout: false # ZITADEL_DEFAULTINSTANCE_FEATURES_ENABLEBACKCHANNELLOGOUT - # LoginV2: - # Required: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED + LoginV2: + Required: true # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED # BaseURI: "" # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI # PermissionCheckV2: false # ZITADEL_DEFAULTINSTANCE_FEATURES_PERMISSIONCHECKV2 # ConsoleUseV2UserApi: false # ZITADEL_DEFAULTINSTANCE_FEATURES_CONSOLEUSEV2USERAPI diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 0000000000..d75373dee1 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,5 @@ +target "typescript-proto-client" { + contexts = { + proto-files = "target:proto-files" + } +} diff --git a/dockerfiles/proto-files.Dockerfile b/dockerfiles/proto-files.Dockerfile new file mode 100644 index 0000000000..0af3346096 --- /dev/null +++ b/dockerfiles/proto-files.Dockerfile @@ -0,0 +1,8 @@ +FROM bufbuild/buf:1.54.0 AS proto-files +RUN buf export https://github.com/envoyproxy/protoc-gen-validate.git --path validate --output /proto-files && \ + buf export https://github.com/grpc-ecosystem/grpc-gateway.git --path protoc-gen-openapiv2 --output /proto-files && \ + buf export https://github.com/googleapis/googleapis.git --path google/api/annotations.proto --path google/api/http.proto --path google/api/field_behavior.proto --output /proto-files + +FROM scratch +COPY --from=proto-files /proto-files / +COPY ./proto / diff --git a/dockerfiles/proto-files.Dockerfile.dockerignore b/dockerfiles/proto-files.Dockerfile.dockerignore new file mode 100644 index 0000000000..e26cd3c2d6 --- /dev/null +++ b/dockerfiles/proto-files.Dockerfile.dockerignore @@ -0,0 +1,2 @@ +* +!proto diff --git a/dockerfiles/typescript-proto-client.Dockerfile b/dockerfiles/typescript-proto-client.Dockerfile new file mode 100644 index 0000000000..4a9505d19d --- /dev/null +++ b/dockerfiles/typescript-proto-client.Dockerfile @@ -0,0 +1,8 @@ +FROM login-pnpm AS typescript-proto-client +COPY ./login/packages/zitadel-proto/package.json ./packages/zitadel-proto/ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --workspace-root --filter zitadel-proto +COPY --from=proto-files /buf.yaml /buf.lock /proto-files/ +COPY --from=proto-files /zitadel /proto-files/zitadel +COPY ./login/packages/zitadel-proto/buf.gen.yaml ./packages/zitadel-proto/ +RUN cd packages/zitadel-proto && pnpm exec buf generate /proto-files diff --git a/dockerfiles/typescript-proto-client.Dockerfile.dockerignore b/dockerfiles/typescript-proto-client.Dockerfile.dockerignore new file mode 100644 index 0000000000..3915a26e4e --- /dev/null +++ b/dockerfiles/typescript-proto-client.Dockerfile.dockerignore @@ -0,0 +1,11 @@ +* +!/login/packages/zitadel-proto/ +login/packages/zitadel-proto/google +login/packages/zitadel-proto/zitadel +login/packages/zitadel-proto/protoc-gen-openapiv2 +login/packages/zitadel-proto/validate + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/e2e/config/host.docker.internal/zitadel.yaml b/e2e/config/host.docker.internal/zitadel.yaml index 203dd16437..23f35302b4 100644 --- a/e2e/config/host.docker.internal/zitadel.yaml +++ b/e2e/config/host.docker.internal/zitadel.yaml @@ -60,6 +60,9 @@ Projections: DefaultInstance: LoginPolicy: MfaInitSkipLifetime: "0" + Features: + LoginV2: + Required: false SystemAPIUsers: - cypress: diff --git a/e2e/config/localhost/zitadel.yaml b/e2e/config/localhost/zitadel.yaml index 966bb4f6b7..701e7b806b 100644 --- a/e2e/config/localhost/zitadel.yaml +++ b/e2e/config/localhost/zitadel.yaml @@ -52,6 +52,9 @@ Quotas: DefaultInstance: LoginPolicy: MfaInitSkipLifetime: "0" + Features: + LoginV2: + Required: false SystemAPIUsers: - cypress: diff --git a/internal/integration/config/zitadel.yaml b/internal/integration/config/zitadel.yaml index bb8d86376d..fed746d823 100644 --- a/internal/integration/config/zitadel.yaml +++ b/internal/integration/config/zitadel.yaml @@ -101,3 +101,10 @@ SystemDefaults: KeyConfig: PrivateKeyLifetime: 7200h PublicKeyLifetime: 14400h + +OIDC: + DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 + DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 + +SAML: + DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 diff --git a/login/.changeset/README.md b/login/.changeset/README.md new file mode 100644 index 0000000000..e5b6d8d6a6 --- /dev/null +++ b/login/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/login/.changeset/config.json b/login/.changeset/config.json new file mode 100644 index 0000000000..3f2d313f66 --- /dev/null +++ b/login/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["@zitadel/login"] +} diff --git a/login/.eslintrc.cjs b/login/.eslintrc.cjs new file mode 100644 index 0000000000..1bfcec169d --- /dev/null +++ b/login/.eslintrc.cjs @@ -0,0 +1,10 @@ +module.exports = { + root: true, + // This tells ESLint to load the config from the package `@zitadel/eslint-config` + extends: ["@zitadel/eslint-config"], + settings: { + next: { + rootDir: ["apps/*/"], + }, + }, +}; diff --git a/login/.github/ISSUE_TEMPLATE/bug.yaml b/login/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 0000000000..2764c1a365 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,63 @@ +name: 🐛 Bug Report +description: "Create a bug report to help us improve ZITADEL Typescript Library." +title: "[Bug]: " +labels: ["bug"] +body: +- type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! +- type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the documentation, the existing issues or discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + validations: + required: true +- type: input + id: version + attributes: + label: Version + description: Which version of ZITADEL Typescript Library are you using. +- type: textarea + id: impact + attributes: + label: Describe the problem caused by this bug + description: A clear and concise description of the problem you have and what the bug is. + validations: + required: true +- type: textarea + id: reproduce + attributes: + label: To reproduce + description: Steps to reproduce the behaviour + placeholder: | + Steps to reproduce the behavior: + 1. ... + validations: + required: true +- type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem. +- type: textarea + id: expected + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. +- type: textarea + id: config + attributes: + label: Relevant Configuration + description: Add any relevant configurations that could help us. Make sure to redact any sensitive information. +- type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/config.yml b/login/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..7e690b9344 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,4 @@ +blank_issues_enabled: true +contact_links: + - name: 💬 ZITADEL Community Chat + url: https://zitadel.com/chat diff --git a/login/.github/ISSUE_TEMPLATE/docs.yaml b/login/.github/ISSUE_TEMPLATE/docs.yaml new file mode 100644 index 0000000000..04c1c0cdb1 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/docs.yaml @@ -0,0 +1,30 @@ +name: 📄 Documentation +description: Create an issue for missing or wrong documentation. +labels: ["docs"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this issue. + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: docs + attributes: + label: Describe the docs your are missing or that are wrong + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/improvement.yaml b/login/.github/ISSUE_TEMPLATE/improvement.yaml new file mode 100644 index 0000000000..cfe79d407b --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/improvement.yaml @@ -0,0 +1,54 @@ +name: 🛠️ Improvement +description: "Create an new issue for an improvment in ZITADEL" +labels: ["improvement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this improvement request + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: problem + attributes: + label: Describe your problem + description: Please describe your problem this improvement is supposed to solve. + placeholder: Describe the problem you have + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe your ideal solution + description: Which solution do you propose? + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: Which version of the typescript library are you using. + - type: dropdown + id: environment + attributes: + label: Environment + description: How do you use ZITADEL? + options: + - ZITADEL Cloud + - Self-hosted + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/proposal.yaml b/login/.github/ISSUE_TEMPLATE/proposal.yaml new file mode 100644 index 0000000000..cd9ff66972 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/proposal.yaml @@ -0,0 +1,54 @@ +name: 💡 Proposal / Feature request +description: "Create an issue for a feature request/proposal." +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this proposal / feature reqeust + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: problem + attributes: + label: Describe your problem + description: Please describe your problem this proposal / feature is supposed to solve. + placeholder: Describe the problem you have. + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe your ideal solution + description: Which solution do you propose? + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: Which version of the Typescript Library are you using. + - type: dropdown + id: environment + attributes: + label: Environment + description: How do you use ZITADEL? + options: + - ZITADEL Cloud + - Self-hosted + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/custom-i18n.png b/login/.github/custom-i18n.png new file mode 100644 index 0000000000000000000000000000000000000000..2306e62f8709d5b6f756b773410eca411b3b5c2e GIT binary patch literal 85028 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 z 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/typescript LOGIN_REMOTE_BRANCH=`. + 4. Push your changes and open a pull request to zitadel/zitadel + `.trim(); + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body: message + }); + await github.rest.pulls.update({ + ...context.repo, + pull_number: context.issue.number, + state: "closed" + }); diff --git a/login/.github/workflows/issues.yml b/login/.github/workflows/issues.yml new file mode 100644 index 0000000000..ff12b8fe04 --- /dev/null +++ b/login/.github/workflows/issues.yml @@ -0,0 +1,41 @@ +name: Add new issues to product management project + +on: + issues: + types: + - opened + +jobs: + add-to-project: + name: Add issue and community pr to project + runs-on: ubuntu-latest + if: github.repository_id == '622995060' + steps: + - name: add issue + uses: actions/add-to-project@v1.0.2 + if: ${{ github.event_name == 'issues' }} + with: + # You can target a repository in a different organization + # to the issue + project-url: https://github.com/orgs/zitadel/projects/2 + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} + - uses: tspascoal/get-user-teams-membership@v3 + id: checkUserMember + if: github.actor != 'dependabot[bot]' + with: + username: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }} + - name: add pr + uses: actions/add-to-project@v1.0.2 + if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'engineers')}} + with: + # You can target a repository in a different organization + # to the issue + project-url: https://github.com/orgs/zitadel/projects/2 + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} + - uses: actions-ecosystem/action-add-labels@v1.1.3 + if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'staff')}} + with: + github_token: ${{ secrets.ADD_TO_PROJECT_PAT }} + labels: | + os-contribution diff --git a/login/.github/workflows/release.yml b/login/.github/workflows/release.yml new file mode 100644 index 0000000000..2508627d1b --- /dev/null +++ b/login/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + runs-on: ubuntu-latest + if: github.repository_id != '622995060' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install + + - name: Create Release Pull Request + uses: changesets/action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/login/.github/workflows/test.yml b/login/.github/workflows/test.yml new file mode 100644 index 0000000000..7b4721dbee --- /dev/null +++ b/login/.github/workflows/test.yml @@ -0,0 +1,67 @@ +name: Quality +on: + pull_request: + workflow_dispatch: + inputs: + ignore-run-cache: + description: 'Whether to ignore the run cache' + required: false + default: true + ref-tag: + description: 'overwrite the DOCKER_METADATA_OUTPUT_VERSION environment variable used by the make file' + required: false + default: '' +jobs: + quality: + name: Ensure Quality + if: github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.repository_id != '622995060') + runs-on: ubuntu-22.04 + timeout-minutes: 30 + permissions: + contents: read # We only need read access to the repository contents + actions: write # We need write access to the actions cache + env: + CACHE_DIR: /tmp/login-run-caches + # Only run this job on workflow_dispatch or pushes to forks + steps: + - uses: actions/checkout@v4 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/zitadel/login + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + # Only with correctly restored build cache layers, the run caches work as expected. + # To restore docker build layer caches, extend the docker-bake.hcl to use the cache-from and cache-to options. + # https://docs.docker.com/build/ci/github-actions/cache/ + # Alternatively, you can use a self-hosted runner or a third-party builder that restores build layer caches out-of-the-box, like https://depot.dev/ + - name: Restore Run Caches + uses: actions/cache/restore@v4 + id: run-caches-restore + with: + path: ${{ env.CACHE_DIR }} + key: ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}-${{github.run_attempt}} + restore-keys: | + ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}- + ${{ runner.os }}-login-run-caches-${{github.ref_name}}- + ${{ runner.os }}-login-run-caches- + - run: make login_quality + env: + IGNORE_RUN_CACHE: ${{ github.event.inputs.ignore-run-cache == 'true' }} + DOCKER_METADATA_OUTPUT_VERSION: ${{ github.event.inputs.ref-tag || env.DOCKER_METADATA_OUTPUT_VERSION || steps.meta.outputs.version }} + - name: Save Run Caches + uses: actions/cache/save@v4 + with: + path: ${{ env.CACHE_DIR }} + key: ${{ steps.run-caches-restore.outputs.cache-primary-key }} + if: always() diff --git a/login/.gitignore b/login/.gitignore new file mode 100644 index 0000000000..8d49ae1b37 --- /dev/null +++ b/login/.gitignore @@ -0,0 +1,18 @@ +.DS_Store +node_modules +.turbo +*.log +.next +dist +dist-ssr +*.local +.env +server/dist +public/dist +.vscode +.idea +.vercel +.env*.local +/blob-report/ +/out +/docker diff --git a/login/.npmrc b/login/.npmrc new file mode 100644 index 0000000000..ded82e2f63 --- /dev/null +++ b/login/.npmrc @@ -0,0 +1 @@ +auto-install-peers = true diff --git a/login/.nvmrc b/login/.nvmrc new file mode 100644 index 0000000000..0a47c855eb --- /dev/null +++ b/login/.nvmrc @@ -0,0 +1 @@ +lts/iron \ No newline at end of file diff --git a/login/.prettierignore b/login/.prettierignore new file mode 100644 index 0000000000..77415caa1e --- /dev/null +++ b/login/.prettierignore @@ -0,0 +1,9 @@ +.next/ +.changeset/ +.github/ +dist/ +standalone/ +packages/zitadel-proto/google +packages/zitadel-proto/protoc-gen-openapiv2 +packages/zitadel-proto/validate +packages/zitadel-proto/zitadel diff --git a/login/.prettierrc b/login/.prettierrc new file mode 100644 index 0000000000..ba42405b03 --- /dev/null +++ b/login/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 125, + "trailingComma": "all", + "plugins": ["prettier-plugin-organize-imports"], + "filepath": "" +} diff --git a/login/CODE_OF_CONDUCT.md b/login/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..ac3f129652 --- /dev/null +++ b/login/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +legal@zitadel.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/login/CONTRIBUTING.md b/login/CONTRIBUTING.md new file mode 100644 index 0000000000..783935984f --- /dev/null +++ b/login/CONTRIBUTING.md @@ -0,0 +1,206 @@ +# Contributing + +:attention: In this CONTRIBUTING.md you read about contributing to this very repository. +If you want to develop your own login UI, please refer [to the README.md](./README.md). + +## Introduction + +Thank you for your interest about how to contribute! + +:attention: If you notice a possible **security vulnerability**, please don't hesitate to disclose any concern by contacting [security@zitadel.com](mailto:security@zitadel.com). +You don't have to be perfectly sure about the nature of the vulnerability. +We will give them a high priority and figure them out. + +We also appreciate all your other ideas, thoughts and feedback and will take care of them as soon as possible. +We love to discuss in an open space using [GitHub issues](https://github.com/zitadel/typescript/issues), +[GitHub discussions in the core repo](https://github.com/zitadel/zitadel/discussions) +or in our [chat on Discord](https://zitadel.com/chat). +For private discussions, +you have [more contact options on our Website](https://zitadel.com/contact). + +## Pull Requests + +Please consider the following guidelines when creating a pull request. + +- The latest changes are always in `main`, so please make your pull request against that branch. +- pull requests should be raised for any change +- Pull requests need approval of a Zitadel core engineer @zitadel/engineers before merging +- We use ESLint/Prettier for linting/formatting, so please run `pnpm lint:fix` before committing to make resolving conflicts easier (VSCode users, check out [this ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) and [this Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to fix lint and formatting issues in development) +- If you add new functionality, please provide the corresponding documentation as well and make it part of the pull request + +### Setting up local environment + +```sh +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Generate gRPC stubs +pnpm generate + +# Start a local development server for the login and manually configure apps/login/.env.local +pnpm dev +``` + +The application is now available at `http://localhost:3000` + +Configure apps/login/.env.local to target the Zitadel instance of your choice. +The login app live-reloads on changes, so you can start developing right away. + +### Developing Against A Local Latest Zitadel Release + +The following command uses Docker to run a local Zitadel instance and the login application in live-reloading dev mode. +Additionally, it runs a Traefik reverse proxy that exposes the login with a self-signed certificate at https://127.0.0.1.sslip.io +127.0.0.1.sslip.io is a special domain that resolves to your localhost, so it's safe to allow your browser to proceed with loading the page. + +```sh +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Generate gRPC stubs +pnpm generate + +# Start a local development server and have apps/login/.env.test.local configured for you to target the local Zitadel instance. +pnpm dev:local +``` + +Log in at https://127.0.0.1.sslip.io/ui/v2/login/loginname and use the following credentials: +**Loginname**: *zitadel-admin@zitadel.127.0.0.1.sslip.io* +**Password**: _Password1!_. + +The login app live-reloads on changes, so you can start developing right away. + +### Developing Against A Locally Compiled Zitadel + +To develop against a locally compiled version of Zitadel, you need to build the Zitadel docker image first. +Clone the [Zitadel repository](https://github.com/zitadel/zitadel.git) and run the following command from its root: + +```sh +# This compiles a Zitadel binary if it does not exist at ./zitadel already and copies it into a Docker image. +# If you want to recompile the binary, run `make compile` first +make login_dev +``` + +Open another terminal session at zitadel/zitadel/login and run the following commands to start the dev server. + +```bash +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Start a local development server and have apps/login/.env.test.local configured for you to target the local Zitadel instance. +NODE_ENV=test pnpm dev +``` + +Log in at https://127.0.0.1.sslip.io/ui/v2/login/loginname and use the following credentials: +**Loginname**: *zitadel-admin@zitadel.127.0.0.1.sslip.io* +**Password**: _Password1!_. + +The login app live-reloads on changes, so you can start developing right away. + +### Quality Assurance + +Use `make` commands to test the quality of your code against a production build without installing any dependencies besides Docker. +Using `make` commands, you can reproduce and debug the CI pipelines locally. + +```sh +# Reproduce the whole CI pipeline in docker +make login_quality +# Show other options with make +make help +``` + +Use `pnpm` commands to run the tests in dev mode with live reloading and debugging capabilities. + +#### Linting and formatting + +Check the formatting and linting of the code in docker + +```sh +make login_lint +``` + +Check the linting of the code using pnpm + +```sh +pnpm lint +pnpm format +``` + +Fix the linting of your code + +```sh +pnpm lint:fix +pnpm format:fix +``` + +#### Running Unit Tests + +Run the tests in docker + +```sh +make login_test_unit +``` + +Run unit tests with live-reloading + +```sh +pnpm test:unit +``` + +#### Running Integration Tests + +Run the test in docker + +```sh +make login_test_integration +``` + +Alternatively, run a live-reloading development server with an interactive Cypress test suite. +First, set up your local test environment. + +```sh +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Generate gRPC stubs +pnpm generate + +# Start a local development server and use apps/login/.env.test to use the locally mocked Zitadel API. +pnpm test:integration:setup +``` + +Now, in another terminal session, open the interactive Cypress integration test suite. + +```sh +pnpm test:integration open +``` + +Show more options with Cypress + +```sh +pnpm test:integration help +``` + +#### Running Acceptance Tests + +To run the tests in docker against the latest release of Zitadel, use the following command: + +:warning: The acceptance tests are not reliable at the moment :construction: + +```sh +make login_test_acceptance +``` + +Alternatively, run can use a live-reloading development server with an interactive Playwright test suite. +Set up your local environment by running the commands either for [developing against a local latest Zitadel release](latest) or for [developing against a locally compiled Zitadel](compiled). + +Now, in another terminal session, open the interactive Playwright acceptance test suite. + +```sh +pnpm test:acceptance open +``` + +Show more options with Playwright + +```sh +pnpm test:acceptance help +``` diff --git a/login/LICENSE b/login/LICENSE new file mode 100644 index 0000000000..89f750f2ab --- /dev/null +++ b/login/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 ZITADEL + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/login/Makefile b/login/Makefile new file mode 100644 index 0000000000..a6e781374b --- /dev/null +++ b/login/Makefile @@ -0,0 +1,137 @@ +XDG_CACHE_HOME ?= $(HOME)/.cache +export CACHE_DIR ?= $(XDG_CACHE_HOME)/zitadel-make + +LOGIN_DIR ?= ./ +LOGIN_BAKE_CLI ?= docker buildx bake +LOGIN_BAKE_CLI_WITH_ARGS := $(LOGIN_BAKE_CLI) --file $(LOGIN_DIR)docker-bake.hcl --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml +LOGIN_BAKE_CLI_ADDITIONAL_ARGS ?= +LOGIN_BAKE_CLI_WITH_ARGS += $(LOGIN_BAKE_CLI_ADDITIONAL_ARGS) + +export COMPOSE_BAKE=true +export UID := $(id -u) +export GID := $(id -g) + +export LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT := $(LOGIN_DIR)apps/login-test-acceptance + +export DOCKER_METADATA_OUTPUT_VERSION ?= local +export LOGIN_TAG ?= login:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_UNIT_TAG := login-test-unit:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_INTEGRATION_TAG := login-test-integration:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_TAG := login-test-acceptance:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SETUP_TAG := login-test-acceptance-setup:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SINK_TAG := login-test-acceptance-sink:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_OIDCRP_TAG := login-test-acceptance-oidcrp:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_OIDCOP_TAG := login-test-acceptance-oidcop:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SAMLSP_TAG := login-test-acceptance-samlsp:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SAMLIDP_TAG := login-test-acceptance-samlidp:${DOCKER_METADATA_OUTPUT_VERSION} +export POSTGRES_TAG := postgres:17.0-alpine3.19 +export GOLANG_TAG := golang:1.24-alpine +export ZITADEL_TAG ?= ghcr.io/zitadel/zitadel:latest +export LOGIN_CORE_MOCK_TAG := login-core-mock:${DOCKER_METADATA_OUTPUT_VERSION} + +login_help: + @echo "Makefile for the login service" + @echo "Available targets:" + @echo " login_help - Show this help message." + @echo " login_quality - Run all quality checks (login_lint, login_test_unit, login_test_integration, login_test_acceptance)." + @echo " login_standalone_build - Build the docker image for production login containers." + @echo " login_lint - Run linting and formatting checks. IGNORE_RUN_CACHE=true prevents skipping." + @echo " login_test_unit - Run unit tests. Tests without any dependencies. IGNORE_RUN_CACHE=true prevents skipping." + @echo " login-test_integration - Run integration tests. Tests a login production build against a mocked Zitadel core API. IGNORE_RUN_CACHE=true prevents skipping." + @echo " login_test_acceptance - Run acceptance tests. Tests a login production build with a local Zitadel instance behind a reverse proxy. IGNORE_RUN_CACHE=true prevents skipping." + @echo " typescript_generate - Generate TypeScript client code from Protobuf definitions." + @echo " show_run_caches - Show all run caches with image ids and exit codes." + @echo " clean_run_caches - Remove all run caches." + + +login_lint: + @echo "Running login linting and formatting checks" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-lint + +login_test_unit: + @echo "Running login unit tests" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-test-unit + +login_test_integration_build: + @echo "Building login integration test environment with the local core mock image" + $(LOGIN_BAKE_CLI_WITH_ARGS) core-mock login-test-integration login-standalone --load + +login_test_integration_dev: login_test_integration_cleanup + @echo "Starting login integration test environment with the local core mock image" + $(LOGIN_BAKE_CLI_WITH_ARGS) core-mock && docker compose --file $(LOGIN_DIR)apps/login-test-integration/docker-compose.yaml run --service-ports --rm core-mock + +login_test_integration_run: login_test_integration_cleanup + @echo "Running login integration tests" + docker compose --file $(LOGIN_DIR)apps/login-test-integration/docker-compose.yaml run --rm integration + +login_test_integration_cleanup: + @echo "Cleaning up login integration test environment" + docker compose --file $(LOGIN_DIR)apps/login-test-integration/docker-compose.yaml down --volumes + +login_test_integration: login_test_integration_build + $(LOGIN_DIR)scripts/run_or_skip.sh login_test_integration_run \ + "$(LOGIN_TAG) \ + $(LOGIN_CORE_MOCK_TAG) \ + $(LOGIN_TEST_INTEGRATION_TAG)" + +login_test_acceptance_build_bake: + @echo "Building login test acceptance images as defined in the docker-bake.hcl" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-test-acceptance login-standalone --load + +login_test_acceptance_build_compose: + @echo "Building login test acceptance images as defined in the docker-compose.yaml" + $(LOGIN_BAKE_CLI_WITH_ARGS) --load setup sink + +# login_test_acceptance_build is overwritten by the login_dev target in zitadel/zitadel/Makefile +login_test_acceptance_build: login_test_acceptance_build_compose login_test_acceptance_build_bake + +login_test_acceptance_run: login_test_acceptance_cleanup + @echo "Running login test acceptance tests" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose-ci.yaml run --rm --service-ports acceptance + +login_test_acceptance_cleanup: + @echo "Cleaning up login test acceptance environment" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose-ci.yaml down --volumes + +login_test_acceptance: login_test_acceptance_build + $(LOGIN_DIR)scripts/run_or_skip.sh login_test_acceptance_run \ + "$(LOGIN_TAG) \ + $(ZITADEL_TAG) \ + $(POSTGRES_TAG) \ + $(GOLANG_TAG) \ + $(LOGIN_TEST_ACCEPTANCE_TAG) \ + $(LOGIN_TEST_ACCEPTANCE_SETUP_TAG) \ + $(LOGIN_TEST_ACCEPTANCE_SINK_TAG)" + +login_test_acceptance_setup_env: login_test_acceptance_build_compose login_test_acceptance_cleanup + @echo "Setting up the login test acceptance environment and writing the env.test.local file" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml run setup + +login_test_acceptance_setup_dev: + @echo "Starting the login test acceptance environment with the local zitadel image" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml up --no-recreate zitadel traefik sink + +login_quality: login_lint login_test_unit login_test_integration + @echo "Running login quality checks: lint, unit tests, integration tests" + +login_standalone_build: + @echo "Building the login standalone docker image with tag: $(LOGIN_TAG)" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-standalone --load + +login_standalone_out: + $(LOGIN_BAKE_CLI_WITH_ARGS) login-standalone-out + +typescript_generate: + @echo "Generating TypeScript client and writing to local $(LOGIN_DIR)packages/zitadel-proto" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-typescript-proto-client-out + +clean_run_caches: + @echo "Removing cache directory: $(CACHE_DIR)" + rm -rf "$(CACHE_DIR)" + +show_run_caches: + @echo "Showing run caches with docker image ids and exit codes in $(CACHE_DIR):" + @find "$(CACHE_DIR)" -type f 2>/dev/null | while read file; do \ + echo "$$file: $$(cat $$file)"; \ + done + diff --git a/login/README.md b/login/README.md new file mode 100644 index 0000000000..c3601e666b --- /dev/null +++ b/login/README.md @@ -0,0 +1,264 @@ +# ZITADEL TypeScript with Turborepo + +This repository contains all TypeScript and JavaScript packages and applications you need to create your own ZITADEL +Login UI. + +collage of login screens + +[![npm package](https://img.shields.io/npm/v/@zitadel/proto.svg?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@zitadel/proto) +[![npm package](https://img.shields.io/npm/v/@zitadel/client.svg?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@zitadel/client) + +**⚠️ This repo and packages are in beta state and subject to change ⚠️** + +The scope of functionality of this repo and packages is under active development. + +The `@zitadel/client` package is using [@connectrpc/connect](https://github.com/connectrpc/connect-es#readme). + +You can read the [contribution guide](/CONTRIBUTING.md) on how to contribute. +Questions can be raised in our [Discord channel](https://discord.gg/erh5Brh7jE) or as +a [GitHub issue](https://github.com/zitadel/typescript/issues). + +## Developing Your Own ZITADEL Login UI + +We think the easiest path of getting up and running, is the following: + +1. Fork and clone this repository +1. [Run the ZITADEL Cloud login UI locally](#run-login-ui) +1. Make changes to the code and see the effects live on your local machine +1. Study the rest of this README.md and get familiar and comfortable with how everything works. +1. Decide on a way of how you want to build and run your login UI. + You can reuse ZITADEL Clouds way. + But if you need more freedom, you can also import the packages you need into your self built application. + +## Included Apps And Packages + +- `login`: The login UI used by ZITADEL Cloud, powered by Next.js +- `@zitadel/client`: shared client utilities for node and browser environments +- `@zitadel/proto`: Protocol Buffers (proto) definitions used by ZITADEL projects +- `@zitadel/tsconfig`: shared `tsconfig.json`s used throughout the monorepo +- `@zitadel/eslint-config`: ESLint preset + +Each package and app is 100% [TypeScript](https://www.typescriptlang.org/). + +### Login + +The login is currently in a work in progress state. +The goal is to implement a login UI, using the session API of ZITADEL, which also implements the OIDC Standard and is +ready to use for everyone. + +In the first phase we want to have a MVP login ready with the OIDC Standard and a basic feature set. In a second step +the features will be extended. + +This list should show the current implementation state, and also what is missing. +You can already use the current state, and extend it with your needs. + +#### Features list + +- [x] Local User Registration (with Password) +- [x] User Registration and Login with external Provider + - [x] Google + - [x] GitHub + - [x] GitHub Enterprise + - [x] GitLab + - [x] GitLab Enterprise + - [x] Azure + - [x] Apple + - [x] Generic OIDC + - [x] Generic OAuth + - [x] Generic JWT + - [x] LDAP + - [x] SAML SP +- Multifactor Registration an Login + - [x] Passkeys + - [x] TOTP + - [x] OTP: Email Code + - [x] OTP: SMS Code +- [x] Password Change/Reset +- [x] Domain Discovery +- [x] Branding +- OIDC Standard + + - [x] Authorization Code Flow with PKCE + - [x] AuthRequest `hintUserId` + - [x] AuthRequest `loginHint` + - [x] AuthRequest `prompt` + - [x] Login + - [x] Select Account + - [ ] Consent + - [x] Create + - Scopes + - [x] `openid email profile address`` + - [x] `offline access` + - [x] `urn:zitadel:iam:org:idp:id:{idp_id}` + - [x] `urn:zitadel:iam:org:project:id:zitadel:aud` + - [x] `urn:zitadel:iam:org:id:{orgid}` + - [x] `urn:zitadel:iam:org:domain:primary:{domain}` + - [ ] AuthRequest UI locales + + #### Flow diagram + + This diagram shows the available pages and flows. + + > Note that back navigation or retries are not displayed. + +```mermaid + flowchart TD + A[Start] --> register + A[Start] --> accounts + A[Start] --> loginname + loginname -- signInWithIDP --> idp-success + loginname -- signInWithIDP --> idp-failure + idp-success --> B[signedin] + loginname --> password + loginname -- hasPasskey --> passkey + loginname -- allowRegister --> register + passkey-add --passwordAllowed --> password + passkey -- hasPassword --> password + passkey --> B[signedin] + password -- hasMFA --> mfa + password -- allowPasskeys --> passkey-add + password -- reset --> password-set + email -- reset --> password-set + password-set --> B[signedin] + password-change --> B[signedin] + password -- userstate=initial --> password-change + + mfa --> otp + otp --> B[signedin] + mfa--> u2f + u2f -->B[signedin] + register -- password/passkey --> B[signedin] + password --> B[signedin] + password-- forceMFA -->mfaset + mfaset --> u2fset + mfaset --> otpset + u2fset --> B[signedin] + otpset --> B[signedin] + accounts--> loginname + password -- not verified yet -->verify + register-- withpassword -->verify + passkey-- notVerified --> verify + verify --> B[signedin] +``` + +You can find a more detailed documentation of the different pages [here](./apps/login/readme.md). + +#### Custom translations + +The new login uses the [SettingsApi](https://zitadel.com/docs/apis/resources/settings_service_v2/settings-service-get-hosted-login-translation) to load custom translations. +Translations can be overriden at both the instance and organization levels. +To find the keys more easily, you can inspect the HTML and search for a `data-i18n-key` attribute, or look at the defaults in `/apps/login/locales/[locale].ts`. +![Custom Translations](.github/custom-i18n.png) + +## Tooling + +- [TypeScript](https://www.typescriptlang.org/) for static type checking +- [ESLint](https://eslint.org/) for code linting +- [Prettier](https://prettier.io) for code formatting + +## Useful Commands + +- `make login-quality` - Check the quality of your code against a production build without installing any dependencies besides Docker +- `pnpm generate` - Build proto stubs for the client package +- `pnpm dev` - Develop all packages and the login app +- `pnpm build` - Build all packages and the login app +- `pnpm clean` - Clean up all `node_modules` and `dist` folders (runs each package's clean script) + +Learn more about developing the login UI in the [contribution guide](/CONTRIBUTING.md). + +## Versioning And Publishing Packages + +Package publishing has been configured using [Changesets](https://github.com/changesets/changesets). +Here is their [documentation](https://github.com/changesets/changesets#documentation) for more information about the +workflow. + +The [GitHub Action](https://github.com/changesets/action) needs an `NPM_TOKEN` and `GITHUB_TOKEN` in the repository +settings. The [Changesets bot](https://github.com/apps/changeset-bot) should also be installed on the GitHub repository. + +Read the [changesets documentation](https://github.com/changesets/changesets/blob/main/docs/automating-changesets.md) +for more information about this automation + +### Run Login UI + +To run the application make sure to install the dependencies with + +```sh +pnpm install +``` + +then generate the GRPC stubs with + +```sh +pnpm generate +``` + +To run the application against a local ZITADEL instance, run the following command: + +```sh +pnpm run-zitadel +``` + +This sets up ZITADEL using docker compose and writes the configuration to the file `apps/login/.env.local`. + +

+Alternatively, use another environment +You can develop against any ZITADEL instance in which you have sufficient rights to execute the following steps. +Just create or overwrite the file `apps/login/.env.local` yourself. +Add your instances base URL to the file at the key `ZITADEL_API_URL`. +Go to your instance and create a service user for the login application. +The login application creates users on your primary organization and reads policy data. +For the sake of simplicity, just make the service user an instance member with the role `IAM_OWNER`. +Create a PAT and copy it to the file `apps/login/.env.local` using the key `ZITADEL_SERVICE_USER_TOKEN`. + +The file should look similar to this: + +``` +ZITADEL_API_URL=https://zitadel-tlx3du.us1.zitadel.cloud +ZITADEL_SERVICE_USER_TOKEN=1S6w48thfWFI2klgfwkCnhXJLf9FQ457E-_3H74ePQxfO3Af0Tm4V5Xi-ji7urIl_xbn-Rk +``` + +
+ +Start the login application in dev mode: + +```sh +pnpm dev +``` + +Open the login application with your favorite browser at `localhost:3000`. +Change the source code and see the changes live in your browser. + +Make sure the application still behaves as expected by running all tests + +```sh +pnpm test +``` + +To satisfy your unique workflow requirements, check out the package.json in the root directory for more detailed scripts. + +### Run Login UI Acceptance tests + +To run the acceptance tests you need a running ZITADEL environment and a component which receives HTTP requests for the emails and sms's. +This component should also be able to return the content of these notifications, as the codes and links are used in the login flows. +There is a basic implementation in Golang available under [the sink package](./acceptance/sink). + +To setup ZITADEL with the additional Sink container for handling the notifications: + +```sh +pnpm run-sink +``` + +Then you can start the acceptance tests with: + +```sh +pnpm test:acceptance +``` + +### Deploy to Vercel + +To deploy your own version on Vercel, navigate to your instance and create a service user. +Then create a personal access token (PAT), copy and set it as ZITADEL_SERVICE_USER_TOKEN, then navigate to your instance +settings and make sure it gets IAM_OWNER permissions. +Finally set your instance url as ZITADEL_API_URL. Make sure to set it without trailing slash. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzitadel%2Ftypescript&env=ZITADEL_API_URL,ZITADEL_SERVICE_USER_TOKEN&root-directory=apps/login&envDescription=Setup%20a%20service%20account%20with%20IAM_LOGIN_CLIENT%20membership%20on%20your%20instance%20and%20provide%20its%20personal%20access%20token.&project-name=zitadel-login&repository-name=zitadel-login) diff --git a/login/acceptance/docker-compose.yaml b/login/acceptance/docker-compose.yaml new file mode 100644 index 0000000000..a68a435e83 --- /dev/null +++ b/login/acceptance/docker-compose.yaml @@ -0,0 +1,71 @@ +services: + zitadel: + user: "${ZITADEL_DEV_UID}" + image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:02617cf17fdde849378c1a6b5254bbfb2745b164}" + command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml' + ports: + - "8080:8080" + volumes: + - ./pat:/pat + - ./zitadel.yaml:/zitadel.yaml + depends_on: + db: + condition: "service_healthy" + extra_hosts: + - "localhost:host-gateway" + + db: + restart: "always" + image: postgres:17.0-alpine3.19 + environment: + - POSTGRES_USER=zitadel + - PGUSER=zitadel + - POSTGRES_DB=zitadel + - POSTGRES_HOST_AUTH_METHOD=trust + command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - 5432:5432 + + wait_for_zitadel: + image: curlimages/curl:8.00.1 + command: /bin/sh -c "until curl -s -o /dev/null -i -f http://zitadel:8080/debug/ready; do echo 'waiting' && sleep 1; done; echo 'ready' && sleep 5;" || false + depends_on: + - zitadel + + setup: + user: "${ZITADEL_DEV_UID}" + container_name: setup + image: acceptance-setup:latest + environment: + PAT_FILE: /pat/zitadel-admin-sa.pat + ZITADEL_API_INTERNAL_URL: http://zitadel:8080 + WRITE_ENVIRONMENT_FILE: /apps/login/.env.local + WRITE_TEST_ENVIRONMENT_FILE: /acceptance/tests/.env.local + SINK_EMAIL_INTERNAL_URL: http://sink:3333/email + SINK_SMS_INTERNAL_URL: http://sink:3333/sms + SINK_NOTIFICATION_URL: http://localhost:3333/notification + volumes: + - "./pat:/pat" + - "../apps/login:/apps/login" + - "../acceptance/tests:/acceptance/tests" + depends_on: + wait_for_zitadel: + condition: "service_completed_successfully" + + sink: + image: golang:1.24-alpine + container_name: sink + command: go run /sink/main.go -port '3333' -email '/email' -sms '/sms' -notification '/notification' + ports: + - 3333:3333 + volumes: + - "./sink:/sink" + depends_on: + setup: + condition: "service_completed_successfully" diff --git a/login/apps/login-test-acceptance/.gitignore b/login/apps/login-test-acceptance/.gitignore new file mode 100644 index 0000000000..6a7425e885 --- /dev/null +++ b/login/apps/login-test-acceptance/.gitignore @@ -0,0 +1 @@ +go-command diff --git a/login/apps/login-test-acceptance/docker-compose-ci.yaml b/login/apps/login-test-acceptance/docker-compose-ci.yaml new file mode 100644 index 0000000000..7a531fcf42 --- /dev/null +++ b/login/apps/login-test-acceptance/docker-compose-ci.yaml @@ -0,0 +1,59 @@ +services: + + zitadel: + environment: + ZITADEL_EXTERNALDOMAIN: traefik + + traefik: + labels: !reset [] + + setup: + environment: + ZITADEL_API_DOMAIN: traefik + ZITADEL_API_URL: https://traefik + LOGIN_BASE_URL: https://traefik/ui/v2/login/ + SINK_NOTIFICATION_URL: http://sink:3333/notification + ZITADEL_ADMIN_USER: zitadel-admin@zitadel.traefik + + login: + image: "${LOGIN_TAG:-login:local}" + container_name: acceptance-login + labels: + - "traefik.enable=true" + - "traefik.http.routers.login.rule=PathPrefix(`/ui/v2/login`)" + ports: + - "3000:3000" + environment: + - NODE_TLS_REJECT_UNAUTHORIZED=0 + depends_on: + setup: + condition: service_completed_successfully + + acceptance: + image: "${LOGIN_TEST_ACCEPTANCE_TAG:-login-test-acceptance:local}" + container_name: acceptance + environment: + - CI + - LOGIN_BASE_URL=https://traefik/ui/v2/login/ + - NODE_TLS_REJECT_UNAUTHORIZED=0 + volumes: + - ../login/.env.test.local:/build/apps/login/.env.test.local + - ./test-results:/build/apps/login-test-acceptance/test-results + - ./playwright-report:/build/apps/login-test-acceptance/playwright-report + ports: + - 9323:9323 + ipc: "host" + init: true + depends_on: + login: + condition: "service_healthy" + sink: + condition: service_healthy +# oidcrp: +# condition: service_healthy +# oidcop: +# condition: service_healthy +# samlsp: +# condition: service_healthy +# samlidp: +# condition: service_healthy diff --git a/login/apps/login-test-acceptance/docker-compose.yaml b/login/apps/login-test-acceptance/docker-compose.yaml new file mode 100644 index 0000000000..cb0463fdc8 --- /dev/null +++ b/login/apps/login-test-acceptance/docker-compose.yaml @@ -0,0 +1,237 @@ +services: + + zitadel: + user: "${UID:-1000}:${GID:-1000}" + image: "${ZITADEL_TAG:-ghcr.io/zitadel/zitadel:latest}" + container_name: acceptance-zitadel + command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --config /zitadel.yaml --steps /zitadel.yaml' + labels: + - "traefik.enable=true" + - "traefik.http.routers.zitadel.rule=!PathPrefix(`/ui/v2/login`)" + # - "traefik.http.middlewares.zitadel.headers.customrequestheaders.Host=localhost" +# - "traefik.http.routers.zitadel.middlewares=zitadel@docker" + - "traefik.http.services.zitadel-service.loadbalancer.server.scheme=h2c" + ports: + - "8080:8080" + volumes: + - ./pat:/pat + - ./zitadel.yaml:/zitadel.yaml + depends_on: + db: + condition: "service_healthy" + + db: + restart: "always" + image: ${LOGIN_TEST_ACCEPTANCE_POSTGES_TAG:-postgres:17.0-alpine3.19} + container_name: acceptance-db + environment: + - POSTGRES_USER=zitadel + - PGUSER=zitadel + - POSTGRES_DB=zitadel + - POSTGRES_HOST_AUTH_METHOD=trust + command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - "5432:5432" + + wait-for-zitadel: + image: curlimages/curl:8.00.1 + container_name: acceptance-wait-for-zitadel + command: /bin/sh -c "until curl -s -o /dev/null -i -f http://zitadel:8080/debug/ready; do echo 'waiting' && sleep 1; done; echo 'ready' && sleep 5;" || false + depends_on: + - zitadel + + traefik: + image: "traefik:v3.4" + container_name: "acceptance-traefik" + labels: + - "traefik.enable=true" + - "traefik.http.routers.login.rule=PathPrefix(`/ui/v2/login`)" + - "traefik.http.services.login-service.loadbalancer.server.url=http://host.docker.internal:3000" + command: +# - "--log.level=DEBUG" + - "--ping" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.websecure.http.tls=true" + - "--entryPoints.websecure.address=:443" + healthcheck: + test: ["CMD", "traefik", "healthcheck", "--ping"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - "443:443" + - "8090:8080" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + extra_hosts: + - host.docker.internal:host-gateway + + setup: + user: "${UID:-1000}:${GID:-1000}" + image: ${LOGIN_TEST_ACCEPTANCE_SETUP_TAG:-login-test-acceptance-setup:local} + container_name: acceptance-setup + restart: no + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/setup" + dockerfile: ../go-command.Dockerfile + entrypoint: "./setup.sh" + environment: + PAT_FILE: /pat/zitadel-admin-sa.pat + ZITADEL_API_INTERNAL_URL: http://zitadel:8080 + WRITE_ENVIRONMENT_FILE: /login-env/.env.test.local + SINK_EMAIL_INTERNAL_URL: http://sink:3333/email + SINK_SMS_INTERNAL_URL: http://sink:3333/sms + SINK_NOTIFICATION_URL: http://localhost:3333/notification + LOGIN_BASE_URL: https://127.0.0.1.sslip.io/ui/v2/login/ + ZITADEL_API_URL: https://127.0.0.1.sslip.io + ZITADEL_API_DOMAIN: 127.0.0.1.sslip.io + ZITADEL_ADMIN_USER: zitadel-admin@zitadel.127.0.0.1.sslip.io + volumes: + - ./pat:/pat # Read the PAT file from zitadels setup + - ../login:/login-env # Write the environment variables file for the login + depends_on: + traefik: + condition: "service_healthy" + wait-for-zitadel: + condition: "service_completed_successfully" + + sink: + image: ${LOGIN_TEST_ACCEPTANCE_SINK_TAG:-login-test-acceptance-sink:local} + container_name: acceptance-sink + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/sink" + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + PORT: '3333' + command: + - -port + - '3333' + - -email + - '/email' + - -sms + - '/sms' + - -notification + - '/notification' + ports: + - "3333:3333" + depends_on: + setup: + condition: "service_completed_successfully" + + oidcrp: + user: "${UID:-1000}:${GID:-1000}" + image: ${LOGIN_TEST_ACCEPTANCE_OIDCRP_TAG:-login-test-acceptance-oidcrp:local} + container_name: acceptance-oidcrp + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/oidcrp" + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + LOGIN_URL: 'https://traefik/ui/v2/login' + ISSUER: 'https://traefik' + HOST: 'traefik' + PORT: '8000' + SCOPES: 'openid profile email' + ports: + - "8000:8000" + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" + + oidcop: + user: "${UID:-1000}:${GID:-1000}" + image: ${LOGIN_TEST_ACCEPTANCE_OIDCOP_TAG:-login-test-acceptance-oidcop:local} + container_name: acceptance-oidcop + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/idp/oidc" + dockerfile: ../../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + SCHEMA: 'https' + HOST: 'traefik' + PORT: "8004" + ports: + - 8004:8004 + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" + + samlsp: + user: "${UID:-1000}:${GID:-1000}" + image: "${LOGIN_TEST_ACCEPTANCE_SAMLSP_TAG:-login-test-acceptance-samlsp:local}" + container_name: acceptance-samlsp + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/samlsp" + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + LOGIN_URL: 'https://traefik/ui/v2/login' + IDP_URL: 'http://zitadel:8080/saml/v2/metadata' + HOST: 'https://traefik' + PORT: '8001' + ports: + - 8001:8001 + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" + + samlidp: + user: "${UID:-1000}:${GID:-1000}" + image: "${LOGIN_TEST_ACCEPTANCE_SAMLIDP_TAG:-login-test-acceptance-samlidp:local}" + container_name: acceptance-samlidp + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/idp/saml" + dockerfile: ../../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik:8080' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + SCHEMA: 'https' + HOST: 'traefik' + PORT: "8003" + ports: + - 8003:8003 + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" diff --git a/login/apps/login-test-acceptance/go-command.Dockerfile b/login/apps/login-test-acceptance/go-command.Dockerfile new file mode 100644 index 0000000000..fafebd6f4d --- /dev/null +++ b/login/apps/login-test-acceptance/go-command.Dockerfile @@ -0,0 +1,11 @@ +ARG LOGIN_TEST_ACCEPTANCE_GOLANG_TAG="golang:1.24-alpine" + +FROM ${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG} +RUN apk add curl jq +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -o /go-command . +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s \ + CMD curl -f http://localhost:${PORT}/healthy || exit 1 +ENTRYPOINT [ "/go-command" ] diff --git a/login/apps/login-test-acceptance/idp/oidc/go.mod b/login/apps/login-test-acceptance/idp/oidc/go.mod new file mode 100644 index 0000000000..84dae766c8 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/oidc/go.mod @@ -0,0 +1,28 @@ +module github.com/zitadel/typescript/acceptance/idp/oidc + +go 1.24.1 + +require github.com/zitadel/oidc/v3 v3.37.0 + +require ( + github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect + github.com/go-chi/chi/v5 v5.2.1 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/muhlemmer/httpforwarded v0.1.0 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/zitadel/logging v0.6.2 // indirect + github.com/zitadel/schema v1.3.1 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/login/apps/login-test-acceptance/idp/oidc/go.sum b/login/apps/login-test-acceptance/idp/oidc/go.sum new file mode 100644 index 0000000000..42d80d8683 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/oidc/go.sum @@ -0,0 +1,71 @@ +github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= +github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU= +github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= +github.com/zitadel/oidc/v3 v3.37.0 h1:nYATWlnP7f18XiAbw6upUruBaqfB1kUrXrSTf1EYGO8= +github.com/zitadel/oidc/v3 v3.37.0/go.mod h1:/xDan4OUQhguJ4Ur73OOJrtugvR164OMnidXP9xfVNw= +github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU= +github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/login/apps/login-test-acceptance/idp/oidc/main.go b/login/apps/login-test-acceptance/idp/oidc/main.go new file mode 100644 index 0000000000..b04ac94234 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/oidc/main.go @@ -0,0 +1,186 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/zitadel/oidc/v3/example/server/exampleop" + "github.com/zitadel/oidc/v3/example/server/storage" +) + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + schema := os.Getenv("SCHEMA") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + logger := slog.New( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelDebug, + }), + ) + + issuer := fmt.Sprintf("%s://%s:%s/", schema, host, port) + redirectURI := fmt.Sprintf("%s/idps/callback", apiURL) + + clientID := "web" + clientSecret := "secret" + storage.RegisterClients( + storage.WebClient(clientID, clientSecret, redirectURI), + ) + + storage := storage.NewStorage(storage.NewUserStore(issuer)) + router := exampleop.SetupServer(issuer, storage, logger, false) + + server := &http.Server{ + Addr: ":" + port, + Handler: router, + } + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + createZitadelResources(apiURL, pat, domain, issuer, clientID, clientSecret) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain, issuer, clientID, clientSecret string) error { + idpID, err := CreateIDP(apiURL, pat, domain, issuer, clientID, clientSecret) + if err != nil { + return err + } + return ActivateIDP(apiURL, pat, domain, idpID) +} + +type createIDP struct { + Name string `json:"name"` + Issuer string `json:"issuer"` + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + Scopes []string `json:"scopes"` + ProviderOptions providerOptions `json:"providerOptions"` + IsIdTokenMapping bool `json:"isIdTokenMapping"` + UsePkce bool `json:"usePkce"` +} + +type providerOptions struct { + IsLinkingAllowed bool `json:"isLinkingAllowed"` + IsCreationAllowed bool `json:"isCreationAllowed"` + IsAutoCreation bool `json:"isAutoCreation"` + IsAutoUpdate bool `json:"isAutoUpdate"` + AutoLinking string `json:"autoLinking"` +} + +type idp struct { + ID string `json:"id"` +} + +func CreateIDP(apiURL, pat, domain string, issuer, clientID, clientSecret string) (string, error) { + createIDP := &createIDP{ + Name: "OIDC", + Issuer: issuer, + ClientId: clientID, + ClientSecret: clientSecret, + Scopes: []string{"openid", "profile", "email"}, + ProviderOptions: providerOptions{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: "AUTO_LINKING_OPTION_USERNAME", + }, + IsIdTokenMapping: false, + UsePkce: false, + } + + resp, err := doRequestWithHeaders(apiURL+"/admin/v1/idps/generic_oidc", pat, domain, createIDP) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + idp := new(idp) + if err := json.Unmarshal(data, idp); err != nil { + return "", err + } + return idp.ID, nil +} + +type activateIDP struct { + IdpId string `json:"idpId"` +} + +func ActivateIDP(apiURL, pat, domain string, idpID string) error { + activateIDP := &activateIDP{ + IdpId: idpID, + } + _, err := doRequestWithHeaders(apiURL+"/admin/v1/policies/login/idps", pat, domain, activateIDP) + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/idp/saml/go.mod b/login/apps/login-test-acceptance/idp/saml/go.mod new file mode 100644 index 0000000000..e73b4feb3b --- /dev/null +++ b/login/apps/login-test-acceptance/idp/saml/go.mod @@ -0,0 +1,16 @@ +module github.com/zitadel/typescript/acceptance/idp/saml + +go 1.24.1 + +require ( + github.com/crewjam/saml v0.4.14 + github.com/mattermost/xml-roundtrip-validator v0.1.0 + github.com/zenazn/goji v1.0.1 + golang.org/x/crypto v0.36.0 +) + +require ( + github.com/beevik/etree v1.1.0 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/russellhaering/goxmldsig v1.3.0 // indirect +) diff --git a/login/apps/login-test-acceptance/idp/saml/go.sum b/login/apps/login-test-acceptance/idp/saml/go.sum new file mode 100644 index 0000000000..1208550f6e --- /dev/null +++ b/login/apps/login-test-acceptance/idp/saml/go.sum @@ -0,0 +1,49 @@ +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= +github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= +github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3cigIwLonTPM= +github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/zenazn/goji v1.0.1 h1:4lbD8Mx2h7IvloP7r2C0D6ltZP6Ufip8Hn0wmSK5LR8= +github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/login/apps/login-test-acceptance/idp/saml/main.go b/login/apps/login-test-acceptance/idp/saml/main.go new file mode 100644 index 0000000000..059eab79e2 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/saml/main.go @@ -0,0 +1,328 @@ +package main + +import ( + "bytes" + "crypto" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "encoding/xml" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/crewjam/saml" + "github.com/crewjam/saml/logger" + "github.com/crewjam/saml/samlidp" + xrv "github.com/mattermost/xml-roundtrip-validator" + "github.com/zenazn/goji" + "github.com/zenazn/goji/bind" + "github.com/zenazn/goji/web" + "golang.org/x/crypto/bcrypt" +) + +var key = func() crypto.PrivateKey { + b, _ := pem.Decode([]byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0OhbMuizgtbFOfwbK7aURuXhZx6VRuAs3nNibiuifwCGz6u9 +yy7bOR0P+zqN0YkjxaokqFgra7rXKCdeABmoLqCC0U+cGmLNwPOOA0PaD5q5xKhQ +4Me3rt/R9C4Ca6k3/OnkxnKwnogcsmdgs2l8liT3qVHP04Oc7Uymq2v09bGb6nPu +fOrkXS9F6mSClxHG/q59AGOWsXK1xzIRV1eu8W2SNdyeFVU1JHiQe444xLoPul5t +InWasKayFsPlJfWNc8EoU8COjNhfo/GovFTHVjh9oUR/gwEFVwifIHihRE0Hazn2 +EQSLaOr2LM0TsRsQroFjmwSGgI+X2bfbMTqWOQIDAQABAoIBAFWZwDTeESBdrLcT +zHZe++cJLxE4AObn2LrWANEv5AeySYsyzjRBYObIN9IzrgTb8uJ900N/zVr5VkxH +xUa5PKbOcowd2NMfBTw5EEnaNbILLm+coHdanrNzVu59I9TFpAFoPavrNt/e2hNo +NMGPSdOkFi81LLl4xoadz/WR6O/7N2famM+0u7C2uBe+TrVwHyuqboYoidJDhO8M +w4WlY9QgAUhkPyzZqrl+VfF1aDTGVf4LJgaVevfFCas8Ws6DQX5q4QdIoV6/0vXi +B1M+aTnWjHuiIzjBMWhcYW2+I5zfwNWRXaxdlrYXRukGSdnyO+DH/FhHePJgmlkj +NInADDkCgYEA6MEQFOFSCc/ELXYWgStsrtIlJUcsLdLBsy1ocyQa2lkVUw58TouW +RciE6TjW9rp31pfQUnO2l6zOUC6LT9Jvlb9PSsyW+rvjtKB5PjJI6W0hjX41wEO6 +fshFELMJd9W+Ezao2AsP2hZJ8McCF8no9e00+G4xTAyxHsNI2AFTCQcCgYEA5cWZ +JwNb4t7YeEajPt9xuYNUOQpjvQn1aGOV7KcwTx5ELP/Hzi723BxHs7GSdrLkkDmi +Gpb+mfL4wxCt0fK0i8GFQsRn5eusyq9hLqP/bmjpHoXe/1uajFbE1fZQR+2LX05N +3ATlKaH2hdfCJedFa4wf43+cl6Yhp6ZA0Yet1r8CgYEAwiu1j8W9G+RRA5/8/DtO +yrUTOfsbFws4fpLGDTA0mq0whf6Soy/96C90+d9qLaC3srUpnG9eB0CpSOjbXXbv +kdxseLkexwOR3bD2FHX8r4dUM2bzznZyEaxfOaQypN8SV5ME3l60Fbr8ajqLO288 +wlTmGM5Mn+YCqOg/T7wjGmcCgYBpzNfdl/VafOROVbBbhgXWtzsz3K3aYNiIjbp+ +MunStIwN8GUvcn6nEbqOaoiXcX4/TtpuxfJMLw4OvAJdtxUdeSmEee2heCijV6g3 +ErrOOy6EqH3rNWHvlxChuP50cFQJuYOueO6QggyCyruSOnDDuc0BM0SGq6+5g5s7 +H++S/wKBgQDIkqBtFr9UEf8d6JpkxS0RXDlhSMjkXmkQeKGFzdoJcYVFIwq8jTNB +nJrVIGs3GcBkqGic+i7rTO1YPkquv4dUuiIn+vKZVoO6b54f+oPBXd4S0BnuEqFE +rdKNuCZhiaE2XD9L/O9KP1fh5bfEcKwazQ23EvpJHBMm8BGC+/YZNw== +-----END RSA PRIVATE KEY-----`)) + k, _ := x509.ParsePKCS1PrivateKey(b.Bytes) + return k +}() + +var cert = func() *x509.Certificate { + b, _ := pem.Decode([]byte(`-----BEGIN CERTIFICATE----- +MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV +BAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5 +NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8A +hs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+a +ucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWx +m+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6 +D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURN +B2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0O +BBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56 +zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5 +pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uv +NONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEf +y/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL +/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsb +GFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTL +UzreO96WzlBBMtY= +-----END CERTIFICATE-----`)) + c, _ := x509.ParseCertificate(b.Bytes) + return c +}() + +// Example from https://github.com/crewjam/saml/blob/main/example/idp/idp.go +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + schema := os.Getenv("SCHEMA") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + baseURL, err := url.Parse(schema + "://" + host + ":" + port) + if err != nil { + + panic(err) + } + + idpServer, err := samlidp.New(samlidp.Options{ + URL: *baseURL, + Logger: logger.DefaultLogger, + Key: key, + Certificate: cert, + Store: &samlidp.MemoryStore{}, + }) + if err != nil { + + panic(err) + } + + metadata, err := xml.MarshalIndent(idpServer.IDP.Metadata(), "", " ") + if err != nil { + panic(err) + } + idpID, err := createZitadelResources(apiURL, pat, domain, metadata) + if err != nil { + panic(err) + } + + lis := bind.Socket(":" + baseURL.Port()) + goji.Handle("/*", idpServer) + + go func() { + goji.ServeListener(lis) + }() + + addService(idpServer, apiURL+"/idps/"+idpID+"/saml/metadata") + addUsers(idpServer) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + if err := lis.Close(); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func addService(idpServer *samlidp.Server, spURLStr string) { + metadataResp, err := http.Get(spURLStr) + if err != nil { + panic(err) + } + defer metadataResp.Body.Close() + + idpServer.HandlePutService( + web.C{URLParams: map[string]string{"id": spURLStr}}, + httptest.NewRecorder(), + httptest.NewRequest(http.MethodPost, spURLStr, metadataResp.Body), + ) +} + +func getSPMetadata(r io.Reader) (spMetadata *saml.EntityDescriptor, err error) { + var data []byte + if data, err = io.ReadAll(r); err != nil { + return nil, err + } + + spMetadata = &saml.EntityDescriptor{} + if err := xrv.Validate(bytes.NewBuffer(data)); err != nil { + return nil, err + } + + if err := xml.Unmarshal(data, &spMetadata); err != nil { + if err.Error() == "expected element type but have " { + entities := &saml.EntitiesDescriptor{} + if err := xml.Unmarshal(data, &entities); err != nil { + return nil, err + } + + for _, e := range entities.EntityDescriptors { + if len(e.SPSSODescriptors) > 0 { + return &e, nil + } + } + + // there were no SPSSODescriptors in the response + return nil, errors.New("metadata contained no service provider metadata") + } + + return nil, err + } + + return spMetadata, nil +} + +func addUsers(idpServer *samlidp.Server) { + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.DefaultCost) + err := idpServer.Store.Put("/users/alice", samlidp.User{Name: "alice", + HashedPassword: hashedPassword, + Groups: []string{"Administrators", "Users"}, + Email: "alice@example.com", + CommonName: "Alice Smith", + Surname: "Smith", + GivenName: "Alice", + }) + if err != nil { + panic(err) + } + + err = idpServer.Store.Put("/users/bob", samlidp.User{ + Name: "bob", + HashedPassword: hashedPassword, + Groups: []string{"Users"}, + Email: "bob@example.com", + CommonName: "Bob Smith", + Surname: "Smith", + GivenName: "Bob", + }) + if err != nil { + panic(err) + } +} + +func createZitadelResources(apiURL, pat, domain string, metadata []byte) (string, error) { + idpID, err := CreateIDP(apiURL, pat, domain, metadata) + if err != nil { + return "", err + } + return idpID, ActivateIDP(apiURL, pat, domain, idpID) +} + +type createIDP struct { + Name string `json:"name"` + MetadataXml string `json:"metadataXml"` + Binding string `json:"binding"` + WithSignedRequest bool `json:"withSignedRequest"` + ProviderOptions providerOptions `json:"providerOptions"` + NameIdFormat string `json:"nameIdFormat"` +} +type providerOptions struct { + IsLinkingAllowed bool `json:"isLinkingAllowed"` + IsCreationAllowed bool `json:"isCreationAllowed"` + IsAutoCreation bool `json:"isAutoCreation"` + IsAutoUpdate bool `json:"isAutoUpdate"` + AutoLinking string `json:"autoLinking"` +} + +type idp struct { + ID string `json:"id"` +} + +func CreateIDP(apiURL, pat, domain string, idpMetadata []byte) (string, error) { + encoded := make([]byte, base64.URLEncoding.EncodedLen(len(idpMetadata))) + base64.URLEncoding.Encode(encoded, idpMetadata) + + createIDP := &createIDP{ + Name: "CREWJAM", + MetadataXml: string(encoded), + Binding: "SAML_BINDING_REDIRECT", + WithSignedRequest: false, + ProviderOptions: providerOptions{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: "AUTO_LINKING_OPTION_USERNAME", + }, + NameIdFormat: "SAML_NAME_ID_FORMAT_PERSISTENT", + } + + resp, err := doRequestWithHeaders(apiURL+"/admin/v1/idps/saml", pat, domain, createIDP) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + idp := new(idp) + if err := json.Unmarshal(data, idp); err != nil { + return "", err + } + return idp.ID, nil +} + +type activateIDP struct { + IdpId string `json:"idpId"` +} + +func ActivateIDP(apiURL, pat, domain string, idpID string) error { + activateIDP := &activateIDP{ + IdpId: idpID, + } + _, err := doRequestWithHeaders(apiURL+"/admin/v1/policies/login/idps", pat, domain, activateIDP) + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/oidcrp/go.mod b/login/apps/login-test-acceptance/oidcrp/go.mod new file mode 100644 index 0000000000..f2cda3058e --- /dev/null +++ b/login/apps/login-test-acceptance/oidcrp/go.mod @@ -0,0 +1,26 @@ +module github.com/zitadel/typescript/acceptance/oidc + +go 1.24.1 + +require ( + github.com/google/uuid v1.6.0 + github.com/sirupsen/logrus v1.9.3 + github.com/zitadel/logging v0.6.1 + github.com/zitadel/oidc/v3 v3.36.1 +) + +require ( + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/zitadel/schema v1.3.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/login/apps/login-test-acceptance/oidcrp/go.sum b/login/apps/login-test-acceptance/oidcrp/go.sum new file mode 100644 index 0000000000..33244ea6eb --- /dev/null +++ b/login/apps/login-test-acceptance/oidcrp/go.sum @@ -0,0 +1,67 @@ +github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= +github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= +github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zitadel/logging v0.6.1 h1:Vyzk1rl9Kq9RCevcpX6ujUaTYFX43aa4LkvV1TvUk+Y= +github.com/zitadel/logging v0.6.1/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= +github.com/zitadel/oidc/v3 v3.36.1 h1:1AT1NqKKEqAwx4GmKJZ9fYkWH2WIn/VKMfQ46nBtRf0= +github.com/zitadel/oidc/v3 v3.36.1/go.mod h1:dApGZLvWZTHRuxmcbQlW5d2XVjVYR3vGOdq536igmTs= +github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= +github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/login/apps/login-test-acceptance/oidcrp/main.go b/login/apps/login-test-acceptance/oidcrp/main.go new file mode 100644 index 0000000000..72ae5f57e9 --- /dev/null +++ b/login/apps/login-test-acceptance/oidcrp/main.go @@ -0,0 +1,322 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "sync/atomic" + "syscall" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + + "github.com/zitadel/logging" + "github.com/zitadel/oidc/v3/pkg/client/rp" + httphelper "github.com/zitadel/oidc/v3/pkg/http" + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +var ( + callbackPath = "/auth/callback" + key = []byte("test1234test1234") +) + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + loginURL := os.Getenv("LOGIN_URL") + issuer := os.Getenv("ISSUER") + port := os.Getenv("PORT") + scopeList := strings.Split(os.Getenv("SCOPES"), " ") + + redirectURI := fmt.Sprintf("%s%s", issuer, callbackPath) + cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure()) + + clientID, clientSecret, err := createZitadelResources(apiURL, pat, domain, redirectURI, loginURL) + if err != nil { + panic(err) + } + + logger := slog.New( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelDebug, + }), + ) + client := &http.Client{ + Timeout: time.Minute, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + // enable outgoing request logging + logging.EnableHTTPClient(client, + logging.WithClientGroup("client"), + ) + + options := []rp.Option{ + rp.WithCookieHandler(cookieHandler), + rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)), + rp.WithHTTPClient(client), + rp.WithLogger(logger), + rp.WithSigningAlgsFromDiscovery(), + rp.WithCustomDiscoveryUrl(issuer + "/.well-known/openid-configuration"), + } + if clientSecret == "" { + options = append(options, rp.WithPKCE(cookieHandler)) + } + + // One can add a logger to the context, + // pre-defining log attributes as required. + ctx := logging.ToContext(context.TODO(), logger) + provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, redirectURI, scopeList, options...) + if err != nil { + logrus.Fatalf("error creating provider %s", err.Error()) + } + + // generate some state (representing the state of the user in your application, + // e.g. the page where he was before sending him to login + state := func() string { + return uuid.New().String() + } + + urlOptions := []rp.URLParamOpt{ + rp.WithPromptURLParam("Welcome back!"), + } + + // register the AuthURLHandler at your preferred path. + // the AuthURLHandler creates the auth request and redirects the user to the auth server. + // including state handling with secure cookie and the possibility to use PKCE. + // Prompts can optionally be set to inform the server of + // any messages that need to be prompted back to the user. + http.Handle("/login", rp.AuthURLHandler( + state, + provider, + urlOptions..., + )) + + // for demonstration purposes the returned userinfo response is written as JSON object onto response + marshalUserinfo := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) { + fmt.Println("access token", tokens.AccessToken) + fmt.Println("refresh token", tokens.RefreshToken) + fmt.Println("id token", tokens.IDToken) + + data, err := json.Marshal(info) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("content-type", "application/json") + w.Write(data) + } + + // register the CodeExchangeHandler at the callbackPath + // the CodeExchangeHandler handles the auth response, creates the token request and calls the callback function + // with the returned tokens from the token endpoint + // in this example the callback function itself is wrapped by the UserinfoCallback which + // will call the Userinfo endpoint, check the sub and pass the info into the callback function + http.Handle(callbackPath, rp.CodeExchangeHandler(rp.UserinfoCallback(marshalUserinfo), provider)) + + // if you would use the callback without calling the userinfo endpoint, simply switch the callback handler for: + // + // http.Handle(callbackPath, rp.CodeExchangeHandler(marshalToken, provider)) + + // simple counter for request IDs + var counter atomic.Int64 + // enable incomming request logging + mw := logging.Middleware( + logging.WithLogger(logger), + logging.WithGroup("server"), + logging.WithIDFunc(func() slog.Attr { + return slog.Int64("id", counter.Add(1)) + }), + ) + + http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) + fmt.Println("/healthy returns 200 OK") + + server := &http.Server{ + Addr: ":" + port, + Handler: mw(http.DefaultServeMux), + } + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain, redirectURI, loginURL string) (string, string, error) { + projectID, err := CreateProject(apiURL, pat, domain) + if err != nil { + return "", "", err + } + return CreateApp(apiURL, pat, domain, projectID, redirectURI, loginURL) +} + +type project struct { + ID string `json:"id"` +} +type createProject struct { + Name string `json:"name"` + ProjectRoleAssertion bool `json:"projectRoleAssertion"` + ProjectRoleCheck bool `json:"projectRoleCheck"` + HasProjectCheck bool `json:"hasProjectCheck"` + PrivateLabelingSetting string `json:"privateLabelingSetting"` +} + +func CreateProject(apiURL, pat, domain string) (string, error) { + createProject := &createProject{ + Name: "OIDC", + ProjectRoleAssertion: false, + ProjectRoleCheck: false, + HasProjectCheck: false, + PrivateLabelingSetting: "PRIVATE_LABELING_SETTING_UNSPECIFIED", + } + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects", pat, domain, createProject) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + p := new(project) + if err := json.Unmarshal(data, p); err != nil { + return "", err + } + fmt.Printf("projectID: %+v\n", p.ID) + return p.ID, nil +} + +type createApp struct { + Name string `json:"name"` + RedirectUris []string `json:"redirectUris"` + ResponseTypes []string `json:"responseTypes"` + GrantTypes []string `json:"grantTypes"` + AppType string `json:"appType"` + AuthMethodType string `json:"authMethodType"` + PostLogoutRedirectUris []string `json:"postLogoutRedirectUris"` + Version string `json:"version"` + DevMode bool `json:"devMode"` + AccessTokenType string `json:"accessTokenType"` + AccessTokenRoleAssertion bool `json:"accessTokenRoleAssertion"` + IdTokenRoleAssertion bool `json:"idTokenRoleAssertion"` + IdTokenUserinfoAssertion bool `json:"idTokenUserinfoAssertion"` + ClockSkew string `json:"clockSkew"` + AdditionalOrigins []string `json:"additionalOrigins"` + SkipNativeAppSuccessPage bool `json:"skipNativeAppSuccessPage"` + BackChannelLogoutUri []string `json:"backChannelLogoutUri"` + LoginVersion version `json:"loginVersion"` +} + +type version struct { + LoginV2 loginV2 `json:"loginV2"` +} +type loginV2 struct { + BaseUri string `json:"baseUri"` +} + +type app struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` +} + +func CreateApp(apiURL, pat, domain, projectID string, redirectURI, loginURL string) (string, string, error) { + createApp := &createApp{ + Name: "OIDC", + RedirectUris: []string{redirectURI}, + ResponseTypes: []string{"OIDC_RESPONSE_TYPE_CODE"}, + GrantTypes: []string{"OIDC_GRANT_TYPE_AUTHORIZATION_CODE"}, + AppType: "OIDC_APP_TYPE_WEB", + AuthMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC", + Version: "OIDC_VERSION_1_0", + DevMode: true, + AccessTokenType: "OIDC_TOKEN_TYPE_BEARER", + AccessTokenRoleAssertion: true, + IdTokenRoleAssertion: true, + IdTokenUserinfoAssertion: true, + ClockSkew: "1s", + SkipNativeAppSuccessPage: true, + LoginVersion: version{ + LoginV2: loginV2{ + BaseUri: loginURL, + }, + }, + } + + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects/"+projectID+"/apps/oidc", pat, domain, createApp) + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + a := new(app) + if err := json.Unmarshal(data, a); err != nil { + return "", "", err + } + return a.ClientID, a.ClientSecret, err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/package.json b/login/apps/login-test-acceptance/package.json new file mode 100644 index 0000000000..1fb83f0345 --- /dev/null +++ b/login/apps/login-test-acceptance/package.json @@ -0,0 +1,18 @@ +{ + "name": "login-test-acceptance", + "private": true, + "scripts": { + "test:acceptance": "dotenv -e ../login/.env.test.local pnpm exec playwright", + "test:acceptance:setup": "cd ../.. && make login_test_acceptance_setup_env && NODE_ENV=test pnpm exec turbo run test:acceptance:setup:dev", + "test:acceptance:setup:dev": "cd ../.. && make login_test_acceptance_setup_dev" + }, + "devDependencies": { + "@faker-js/faker": "^9.7.0", + "@otplib/core": "^12.0.0", + "@otplib/plugin-crypto": "^12.0.0", + "@otplib/plugin-thirty-two": "^12.0.0", + "@playwright/test": "^1.52.0", + "gaxios": "^7.1.0", + "typescript": "^5.8.3" + } +} diff --git a/login/apps/login-test-acceptance/pat/.gitignore b/login/apps/login-test-acceptance/pat/.gitignore new file mode 100644 index 0000000000..377ccd3fdf --- /dev/null +++ b/login/apps/login-test-acceptance/pat/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/login/apps/login-test-acceptance/pat/.gitkeep b/login/apps/login-test-acceptance/pat/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/playwright-report/.gitignore b/login/apps/login-test-acceptance/playwright-report/.gitignore new file mode 100644 index 0000000000..377ccd3fdf --- /dev/null +++ b/login/apps/login-test-acceptance/playwright-report/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/login/apps/login-test-acceptance/playwright-report/.gitkeep b/login/apps/login-test-acceptance/playwright-report/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/playwright.config.ts b/login/apps/login-test-acceptance/playwright.config.ts new file mode 100644 index 0000000000..8025db3238 --- /dev/null +++ b/login/apps/login-test-acceptance/playwright.config.ts @@ -0,0 +1,78 @@ +import { defineConfig, devices } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; + +dotenv.config({ path: path.resolve(__dirname, "../login/.env.test.local") }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./tests", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + expect: { + timeout: 10_000, // 10 seconds + }, + timeout: 300 * 1000, // 5 minutes + globalTimeout: 30 * 60_000, // 30 minutes + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ["line"], + ["html", { open: process.env.CI ? "never" : "on-failure", host: "0.0.0.0", outputFolder: "./playwright-report/html" }], + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.LOGIN_BASE_URL || "http://127.0.0.1:3000", + trace: "retain-on-failure", + headless: true, + screenshot: "only-on-failure", + video: "retain-on-failure", + ignoreHTTPSErrors: true, + }, + outputDir: "test-results/results", + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + /* + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + TODO: webkit fails. Is this a bug? + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + */ + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], +}); diff --git a/login/apps/login-test-acceptance/samlsp/go.mod b/login/apps/login-test-acceptance/samlsp/go.mod new file mode 100644 index 0000000000..9986149bfb --- /dev/null +++ b/login/apps/login-test-acceptance/samlsp/go.mod @@ -0,0 +1,18 @@ +module github.com/zitadel/typescript/acceptance/saml + +go 1.24.0 + +require github.com/crewjam/saml v0.4.14 + +require ( + github.com/beevik/etree v1.5.0 // indirect + github.com/crewjam/httperr v0.2.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/russellhaering/goxmldsig v1.5.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + golang.org/x/crypto v0.36.0 // indirect +) diff --git a/login/apps/login-test-acceptance/samlsp/go.sum b/login/apps/login-test-acceptance/samlsp/go.sum new file mode 100644 index 0000000000..3394a39410 --- /dev/null +++ b/login/apps/login-test-acceptance/samlsp/go.sum @@ -0,0 +1,38 @@ +github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs= +github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= +github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo= +github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4= +github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= +github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russellhaering/goxmldsig v1.5.0 h1:AU2UkkYIUOTyZRbe08XMThaOCelArgvNfYapcmSjBNw= +github.com/russellhaering/goxmldsig v1.5.0/go.mod h1:x98CjQNFJcWfMxeOrMnMKg70lvDP6tE0nTaeUnjXDmk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/login/apps/login-test-acceptance/samlsp/main.go b/login/apps/login-test-acceptance/samlsp/main.go new file mode 100644 index 0000000000..9dcfd13796 --- /dev/null +++ b/login/apps/login-test-acceptance/samlsp/main.go @@ -0,0 +1,271 @@ +package main + +import ( + "bytes" + "context" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/crewjam/saml/samlsp" +) + +var keyPair = func() tls.Certificate { + cert := []byte(`-----BEGIN CERTIFICATE----- +MIIDITCCAgmgAwIBAgIUKjAUmxsHO44X+/TKBNciPgNl1GEwDQYJKoZIhvcNAQEL +BQAwIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1wbGUuY29tMB4XDTI0MTIxOTEz +Mzc1MVoXDTI1MTIxOTEzMzc1MVowIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1w +bGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0QYuJsayILRI +hVT7G1DlitVSXnt1iw3gEXJZfe81Egz06fUbvXF6Yo1LJmwYpqe/rm+hf4FNUb8e +2O+LH2FieA9FkVe4P2gKOzw87A/KxvpV8stgNgl4LlqRCokbc1AzeE/NiLr5TcTD +RXm3DUcYxXxinprtDu2jftFysaOZmNAukvE/iL6qS3X6ggVEDDM7tY9n5FV2eJ4E +p0ImKfypi2aZYROxOK+v5x9ryFRMl4y07lMDvmtcV45uXYmfGNCgG9PNf91Kk/mh +JxEQbxycJwFoSi9XWljR8ahPdO11LXG7Dsj/RVbY8k2LdKNstl6Ae3aCpbe9u2Pj +vxYs1bVJuQIDAQABo1MwUTAdBgNVHQ4EFgQU+mRVN5HYJWgnpopReaLhf2cMcoYw +HwYDVR0jBBgwFoAU+mRVN5HYJWgnpopReaLhf2cMcoYwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEABJpHVuc9tGhD04infRVlofvqXIUizTlOrjZX +vozW9pIhSWEHX8o+sJP8AMZLnrsdq+bm0HE0HvgYrw7Lb8pd4FpR46TkFHjeukoj +izqfgckjIBl2nwPGlynbKA0/U/rTCSxVt7XiAn+lgYUGIpOzNdk06/hRMitrMNB7 +t2C97NseVC4b1ZgyFrozsefCfUmD8IJF0+XJ4Wzmsh0jRrI8koCtVmPYnKn6vw1b +cZprg/97CWHYrsavd406wOB60CMtYl83Q16ucOF1dretDFqJC5kY+aFLvuqfag2+ +kIaoPV1MnGsxveQyyHdOsEatS5XOv/1OWcmnvePDPxcvb9jCcw== +-----END CERTIFICATE----- +`) + key := []byte(`-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRBi4mxrIgtEiF +VPsbUOWK1VJee3WLDeARcll97zUSDPTp9Ru9cXpijUsmbBimp7+ub6F/gU1Rvx7Y +74sfYWJ4D0WRV7g/aAo7PDzsD8rG+lXyy2A2CXguWpEKiRtzUDN4T82IuvlNxMNF +ebcNRxjFfGKemu0O7aN+0XKxo5mY0C6S8T+IvqpLdfqCBUQMMzu1j2fkVXZ4ngSn +QiYp/KmLZplhE7E4r6/nH2vIVEyXjLTuUwO+a1xXjm5diZ8Y0KAb081/3UqT+aEn +ERBvHJwnAWhKL1daWNHxqE907XUtcbsOyP9FVtjyTYt0o2y2XoB7doKlt727Y+O/ +FizVtUm5AgMBAAECggEACak+l5f6Onj+u5vrjc4JyAaXW6ra6loSM9g8Uu3sHukW +plwoA7Pzp0u20CAxrP1Gpqw984/hSCCcb0Q2ItWMWLaC/YZni5W2WFnOyo3pzlPa +hmH4UNMT+ReCSfF/oW8w69QLcNEMjhfEu0i2iWBygIlA4SoRwC2Db6yEX7nLMwUB +6AICid9hfeACNRz/nq5ytdcHdmcB7Ptgb9jLiXr6RZw26g5AsRPHU3LdcyZAOXjP +aUHriHuHQFKAVkoEUxslvCB6ePCTCpB0bSAuzQbeGoY8fmvmNSCvJ1vrH5hiSUYp +Axtl5iNgFl5o9obb0eBYlY9x3pMSz0twdbCwfR7HAQKBgQDtWhmFm0NaJALoY+tq +lIIC0EOMSrcRIlgeXr6+g8womuDOMi5m/Nr5Mqt4mPOdP4HytrQb+a/ZmEm17KHh +mQb1vwH8ffirCBHbPNC1vwSNoxDKv9E6OysWlKiOzxPFSVZr3dKl2EMX6qi17n0l +LBrGXXaNPgYiHSmwBA5CZvvouQKBgQDhclGJfZfuoubQkUuz8yOA2uxalh/iUmQ/ +G8ac6/w7dmnL9pXehqCWh06SeC3ZvW7yrf7IIGx4sTJji2FzQ+8Ta6pPELMyBEXr +1VirIFrlNVMlMQEbZcbzdzEhchM1RUpZJtl3b4amvH21UcRB69d9klcDRisKoFRm +k0P9QLHpAQKBgQDh5J9nphZa4u0ViYtTW1XFIbs3+R/0IbCl7tww67TRbF3KQL4i +7EHna88ALumkXf3qJvKRsXgoaqS0jSqgUAjst8ZHLQkOldaQxneIkezedDSWEisp +9YgTrJYjnHefiyXB8VL63jE0wPOiewEF8Mzmv6sFz+L8cq7rQ2Di16qmmQKBgQDH +bvCwVxkrMpJK2O2GH8U9fOzu6bUE6eviY/jb4mp8U7EdjGJhuuieoM2iBoxQ/SID +rmYftYcfcWlo4+juJZ99p5W+YcCTs3IDQPUyVOnzr6uA0Avxp6RKxhsBQj+5tTUj +Dpn77P3JzB7MYqvhwPcdD3LH46+5s8FWCFpx02RPAQKBgARbngtggfifatcsMC7n +lSv/FVLH7LYQAHdoW/EH5Be7FeeP+eQvGXwh1dgl+u0VZO8FvI8RwFganpBRR2Nc +ZSBRIb0fSUlTvIsckSWjpEvUJUomJXyi4PIZAfNvd9/u1uLInQiCDtObwb6hnLTU +FHHEZ+dR4eMaJp6PhNm8hu2O +-----END PRIVATE KEY----- +`) + + kp, err := tls.X509KeyPair(cert, key) + if err != nil { + panic(err) + } + kp.Leaf, err = x509.ParseCertificate(kp.Certificate[0]) + if err != nil { + panic(err) + } + return kp +}() + +func hello(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello, %s!", samlsp.AttributeFromContext(r.Context(), "UserName")) +} + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + loginURL := os.Getenv("LOGIN_URL") + idpURL := os.Getenv("IDP_URL") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + idpMetadataURL, err := url.Parse(idpURL) + if err != nil { + panic(err) + } + idpMetadata, err := samlsp.FetchMetadata(context.Background(), http.DefaultClient, + *idpMetadataURL) + if err != nil { + panic(fmt.Errorf("failed to fetch IDP metadata from %s: %w", idpURL, err)) + } + fmt.Printf("idpMetadata: %+v\n", idpMetadata) + rootURL, err := url.Parse(host + ":" + port) + if err != nil { + panic(err) + } + + samlSP, err := samlsp.New(samlsp.Options{ + URL: *rootURL, + Key: keyPair.PrivateKey.(*rsa.PrivateKey), + Certificate: keyPair.Leaf, + IDPMetadata: idpMetadata, + }) + if err != nil { + panic(err) + } + + server := &http.Server{ + Addr: ":" + port, + } + app := http.HandlerFunc(hello) + http.Handle("/hello", samlSP.RequireAccount(app)) + http.Handle("/saml/", samlSP) + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + metadata, err := xml.MarshalIndent(samlSP.ServiceProvider.Metadata(), "", " ") + if err != nil { + panic(err) + } + if err := createZitadelResources(apiURL, pat, domain, metadata, loginURL); err != nil { + panic(err) + } + + http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) + fmt.Println("/healthy returns 200 OK") + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain string, metadata []byte, loginURL string) error { + projectID, err := CreateProject(apiURL, pat, domain) + if err != nil { + return err + } + return CreateApp(apiURL, pat, domain, projectID, metadata, loginURL) +} + +type project struct { + ID string `json:"id"` +} +type createProject struct { + Name string `json:"name"` + ProjectRoleAssertion bool `json:"projectRoleAssertion"` + ProjectRoleCheck bool `json:"projectRoleCheck"` + HasProjectCheck bool `json:"hasProjectCheck"` + PrivateLabelingSetting string `json:"privateLabelingSetting"` +} + +func CreateProject(apiURL, pat, domain string) (string, error) { + createProject := &createProject{ + Name: "SAML", + ProjectRoleAssertion: false, + ProjectRoleCheck: false, + HasProjectCheck: false, + PrivateLabelingSetting: "PRIVATE_LABELING_SETTING_UNSPECIFIED", + } + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects", pat, domain, createProject) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + p := new(project) + if err := json.Unmarshal(data, p); err != nil { + return "", err + } + return p.ID, nil +} + +type createApp struct { + Name string `json:"name"` + MetadataXml string `json:"metadataXml"` + LoginVersion version `json:"loginVersion"` +} +type version struct { + LoginV2 loginV2 `json:"loginV2"` +} +type loginV2 struct { + BaseUri string `json:"baseUri"` +} + +func CreateApp(apiURL, pat, domain, projectID string, spMetadata []byte, loginURL string) error { + encoded := make([]byte, base64.URLEncoding.EncodedLen(len(spMetadata))) + base64.URLEncoding.Encode(encoded, spMetadata) + + createApp := &createApp{ + Name: "SAML", + MetadataXml: string(encoded), + LoginVersion: version{ + LoginV2: loginV2{ + BaseUri: loginURL, + }, + }, + } + _, err := doRequestWithHeaders(apiURL+"/management/v1/projects/"+projectID+"/apps/saml", pat, domain, createApp) + if err != nil { + return fmt.Errorf("error creating saml app with request %+v: %v", *createApp, err) + } + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/setup/go.mod b/login/apps/login-test-acceptance/setup/go.mod new file mode 100644 index 0000000000..7be166ef9b --- /dev/null +++ b/login/apps/login-test-acceptance/setup/go.mod @@ -0,0 +1,3 @@ +module github.com/zitadel/typescript/apps/login-test-acceptance/setup + +go 1.23.3 diff --git a/login/apps/login-test-acceptance/setup/go.sum b/login/apps/login-test-acceptance/setup/go.sum new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/setup/main.go b/login/apps/login-test-acceptance/setup/main.go new file mode 100644 index 0000000000..38dd16da61 --- /dev/null +++ b/login/apps/login-test-acceptance/setup/main.go @@ -0,0 +1,3 @@ +package main + +func main() {} diff --git a/login/apps/login-test-acceptance/setup/setup.sh b/login/apps/login-test-acceptance/setup/setup.sh new file mode 100755 index 0000000000..9d1a04e18f --- /dev/null +++ b/login/apps/login-test-acceptance/setup/setup.sh @@ -0,0 +1,139 @@ +#!/bin/sh + +set -e pipefail + +PAT_FILE=${PAT_FILE:-./pat/zitadel-admin-sa.pat} +LOGIN_BASE_URL=${LOGIN_BASE_URL:-"http://localhost:3000"} +ZITADEL_API_PROTOCOL="${ZITADEL_API_PROTOCOL:-http}" +ZITADEL_API_DOMAIN="${ZITADEL_API_DOMAIN:-localhost}" +ZITADEL_API_PORT="${ZITADEL_API_PORT:-8080}" +ZITADEL_API_URL="${ZITADEL_API_URL:-${ZITADEL_API_PROTOCOL}://${ZITADEL_API_DOMAIN}:${ZITADEL_API_PORT}}" +ZITADEL_API_INTERNAL_URL="${ZITADEL_API_INTERNAL_URL:-${ZITADEL_API_URL}}" +SINK_EMAIL_INTERNAL_URL="${SINK_EMAIL_INTERNAL_URL:-"http://sink:3333/email"}" +SINK_SMS_INTERNAL_URL="${SINK_SMS_INTERNAL_URL:-"http://sink:3333/sms"}" +SINK_NOTIFICATION_URL="${SINK_NOTIFICATION_URL:-"http://localhost:3333/notification"}" +WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.test.local} + +if [ -z "${PAT}" ]; then + echo "Reading PAT from file ${PAT_FILE}" + PAT=$(cat ${PAT_FILE}) +fi + +################################################################# +# ServiceAccount as Login Client +################################################################# + +SERVICEACCOUNT_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/management/v1/users/machine" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"userName\": \"login\", \"name\": \"Login v2\", \"description\": \"Serviceaccount for Login v2\", \"accessTokenType\": \"ACCESS_TOKEN_TYPE_BEARER\"}") +echo "Received ServiceAccount response: ${SERVICEACCOUNT_RESPONSE}" + +SERVICEACCOUNT_ID=$(echo ${SERVICEACCOUNT_RESPONSE} | jq -r '. | .userId') +echo "Received ServiceAccount ID: ${SERVICEACCOUNT_ID}" + +MEMBER_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/members" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"userId\": \"${SERVICEACCOUNT_ID}\", \"roles\": [\"IAM_LOGIN_CLIENT\"]}") +echo "Received Member response: ${MEMBER_RESPONSE}" + +SA_PAT_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/management/v1/users/${SERVICEACCOUNT_ID}/pats" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"expirationDate\": \"2519-04-01T08:45:00.000000Z\"}") +echo "Received Member response: ${MEMBER_RESPONSE}" + +SA_PAT=$(echo ${SA_PAT_RESPONSE} | jq -r '. | .token') +echo "Received ServiceAccount Token: ${SA_PAT}" + +################################################################# +# Environment files +################################################################# + +echo "Writing environment file ${WRITE_ENVIRONMENT_FILE}." + +echo "ZITADEL_API_URL=${ZITADEL_API_URL} +ZITADEL_SERVICE_USER_TOKEN=${SA_PAT} +ZITADEL_ADMIN_TOKEN=${PAT} +SINK_NOTIFICATION_URL=${SINK_NOTIFICATION_URL} +EMAIL_VERIFICATION=true +DEBUG=false +LOGIN_BASE_URL=${LOGIN_BASE_URL} +NODE_TLS_REJECT_UNAUTHORIZED=0 +ZITADEL_ADMIN_USER=${ZITADEL_ADMIN_USER:-"zitadel-admin@zitadel.localhost"} +NEXT_PUBLIC_BASE_PATH=/ui/v2/login +" > ${WRITE_ENVIRONMENT_FILE} + +echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}" +cat ${WRITE_ENVIRONMENT_FILE} + +################################################################# +# SMS provider with HTTP +################################################################# + +SMSHTTP_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/http" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"endpoint\": \"${SINK_SMS_INTERNAL_URL}\", \"description\": \"test\"}") +echo "Received SMS HTTP response: ${SMSHTTP_RESPONSE}" + +SMSHTTP_ID=$(echo ${SMSHTTP_RESPONSE} | jq -r '. | .id') +echo "Received SMS HTTP ID: ${SMSHTTP_ID}" + +SMS_ACTIVE_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/${SMSHTTP_ID}/_activate" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json") +echo "Received SMS active response: ${SMS_ACTIVE_RESPONSE}" + +################################################################# +# Email provider with HTTP +################################################################# + +EMAILHTTP_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/http" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"endpoint\": \"${SINK_EMAIL_INTERNAL_URL}\", \"description\": \"test\"}") +echo "Received Email HTTP response: ${EMAILHTTP_RESPONSE}" + +EMAILHTTP_ID=$(echo ${EMAILHTTP_RESPONSE} | jq -r '. | .id') +echo "Received Email HTTP ID: ${EMAILHTTP_ID}" + +EMAIL_ACTIVE_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/${EMAILHTTP_ID}/_activate" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json") +echo "Received Email active response: ${EMAIL_ACTIVE_RESPONSE}" + +################################################################# +# Wait for projection of default organization in ZITADEL +################################################################# + +DEFAULTORG_RESPONSE_RESULTS=0 +# waiting for default organization +until [ ${DEFAULTORG_RESPONSE_RESULTS} -eq 1 ] +do + DEFAULTORG_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/v2/organizations/_search" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"queries\": [{\"defaultQuery\":{}}]}" ) + echo "Received default organization response: ${DEFAULTORG_RESPONSE}" + DEFAULTORG_RESPONSE_RESULTS=$(echo $DEFAULTORG_RESPONSE | jq -r '.result | length') + echo "Received default organization response result: ${DEFAULTORG_RESPONSE_RESULTS}" +done + diff --git a/login/apps/login-test-acceptance/sink/go.mod b/login/apps/login-test-acceptance/sink/go.mod new file mode 100644 index 0000000000..1da7622b58 --- /dev/null +++ b/login/apps/login-test-acceptance/sink/go.mod @@ -0,0 +1,3 @@ +module github.com/zitadel/typescript/acceptance/sink + +go 1.24.0 diff --git a/login/apps/login-test-acceptance/sink/go.sum b/login/apps/login-test-acceptance/sink/go.sum new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/sink/main.go b/login/apps/login-test-acceptance/sink/main.go new file mode 100644 index 0000000000..f3795ba0d0 --- /dev/null +++ b/login/apps/login-test-acceptance/sink/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net/http" +) + +type serializableData struct { + ContextInfo map[string]interface{} `json:"contextInfo,omitempty"` + Args map[string]interface{} `json:"args,omitempty"` +} + +type response struct { + Recipient string `json:"recipient,omitempty"` +} + +func main() { + port := flag.String("port", "3333", "used port for the sink") + email := flag.String("email", "/email", "path for a sent email") + emailKey := flag.String("email-key", "recipientEmailAddress", "value in the sent context info of the email used as key to retrieve the notification") + sms := flag.String("sms", "/sms", "path for a sent sms") + smsKey := flag.String("sms-key", "recipientPhoneNumber", "value in the sent context info of the sms used as key to retrieve the notification") + notification := flag.String("notification", "/notification", "path to receive the notification") + flag.Parse() + + messages := make(map[string]serializableData) + + http.HandleFunc(*email, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + serializableData := serializableData{} + if err := json.Unmarshal(data, &serializableData); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + email, ok := serializableData.ContextInfo[*emailKey].(string) + if !ok { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + fmt.Println(email + ": " + string(data)) + messages[email] = serializableData + io.WriteString(w, "Email!\n") + }) + + http.HandleFunc(*sms, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + serializableData := serializableData{} + if err := json.Unmarshal(data, &serializableData); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + phone, ok := serializableData.ContextInfo[*smsKey].(string) + if !ok { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + fmt.Println(phone + ": " + string(data)) + messages[phone] = serializableData + io.WriteString(w, "SMS!\n") + }) + + http.HandleFunc(*notification, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + response := response{} + if err := json.Unmarshal(data, &response); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + msg, ok := messages[response.Recipient] + if !ok { + http.Error(w, "No messages found for recipient: "+response.Recipient, http.StatusNotFound) + return + } + serializableData, err := json.Marshal(msg) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, string(serializableData)) + }) + + fmt.Println("Starting server on", *port) + fmt.Println(*email, " for email handling") + fmt.Println(*sms, " for sms handling") + fmt.Println(*notification, " for retrieving notifications") + http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) + fmt.Println("/healthy returns 200 OK") + err := http.ListenAndServe(":"+*port, nil) + if err != nil { + panic("Server could not be started: " + err.Error()) + } +} diff --git a/login/apps/login-test-acceptance/test-results/.gitignore b/login/apps/login-test-acceptance/test-results/.gitignore new file mode 100644 index 0000000000..377ccd3fdf --- /dev/null +++ b/login/apps/login-test-acceptance/test-results/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/login/apps/login-test-acceptance/test-results/.gitkeep b/login/apps/login-test-acceptance/test-results/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/tests/admin.spec.ts b/login/apps/login-test-acceptance/tests/admin.spec.ts new file mode 100644 index 0000000000..13b748fc63 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/admin.spec.ts @@ -0,0 +1,7 @@ +import { test } from "@playwright/test"; +import { loginScreenExpect, loginWithPassword } from "./login"; + +test("admin login", async ({ page }) => { + await loginWithPassword(page, process.env["ZITADEL_ADMIN_USER"], "Password1!"); + await loginScreenExpect(page, "ZITADEL Admin"); +}); diff --git a/login/apps/login-test-acceptance/tests/code-screen.ts b/login/apps/login-test-acceptance/tests/code-screen.ts new file mode 100644 index 0000000000..3ab9dad26d --- /dev/null +++ b/login/apps/login-test-acceptance/tests/code-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const codeTextInput = "code-text-input"; + +export async function codeScreen(page: Page, code: string) { + await page.getByTestId(codeTextInput).pressSequentially(code); +} + +export async function codeScreenExpect(page: Page, code: string) { + await expect(page.getByTestId(codeTextInput)).toHaveValue(code); + await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify OTP code"); +} diff --git a/login/apps/login-test-acceptance/tests/code.ts b/login/apps/login-test-acceptance/tests/code.ts new file mode 100644 index 0000000000..e27d1f6150 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/code.ts @@ -0,0 +1,17 @@ +import { Page } from "@playwright/test"; +import { codeScreen } from "./code-screen"; +import { getOtpFromSink } from "./sink"; + +export async function otpFromSink(page: Page, key: string) { + const c = await getOtpFromSink(key); + await code(page, c); +} + +export async function code(page: Page, code: string) { + await codeScreen(page, code); + await page.getByTestId("submit-button").click(); +} + +export async function codeResend(page: Page) { + await page.getByTestId("resend-button").click(); +} diff --git a/login/apps/login-test-acceptance/tests/email-verify-screen.ts b/login/apps/login-test-acceptance/tests/email-verify-screen.ts new file mode 100644 index 0000000000..b077ecb424 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/email-verify-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const codeTextInput = "code-text-input"; + +export async function emailVerifyScreen(page: Page, code: string) { + await page.getByTestId(codeTextInput).pressSequentially(code); +} + +export async function emailVerifyScreenExpect(page: Page, code: string) { + await expect(page.getByTestId(codeTextInput)).toHaveValue(code); + await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify email"); +} diff --git a/login/apps/login-test-acceptance/tests/email-verify.spec.ts b/login/apps/login-test-acceptance/tests/email-verify.spec.ts new file mode 100644 index 0000000000..2c546b8eee --- /dev/null +++ b/login/apps/login-test-acceptance/tests/email-verify.spec.ts @@ -0,0 +1,69 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { emailVerify, emailVerifyResend } from "./email-verify"; +import { emailVerifyScreenExpect } from "./email-verify-screen"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { getCodeFromSink } from "./sink"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: false, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("user email not verified, verify", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + const c = await getCodeFromSink(user.getUsername()); + await emailVerify(page, c); + // wait for resend of the code + await page.waitForTimeout(2000); + await loginScreenExpect(page, user.getFullName()); +}); + +test("user email not verified, resend, verify", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + await emailVerifyResend(page); + const c = await getCodeFromSink(user.getUsername()); + // wait for resend of the code + await page.waitForTimeout(2000); + await emailVerify(page, c); + await loginScreenExpect(page, user.getFullName()); +}); + +test("user email not verified, resend, old code", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + const c = await getCodeFromSink(user.getUsername()); + await emailVerifyResend(page); + // wait for resend of the code + await page.waitForTimeout(2000); + await emailVerify(page, c); + await emailVerifyScreenExpect(page, c); +}); + +test("user email not verified, wrong code", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + const code = "wrong"; + await emailVerify(page, code); + await emailVerifyScreenExpect(page, code); +}); diff --git a/login/apps/login-test-acceptance/tests/email-verify.ts b/login/apps/login-test-acceptance/tests/email-verify.ts new file mode 100644 index 0000000000..5275e82bfe --- /dev/null +++ b/login/apps/login-test-acceptance/tests/email-verify.ts @@ -0,0 +1,15 @@ +import { Page } from "@playwright/test"; +import { emailVerifyScreen } from "./email-verify-screen"; + +export async function startEmailVerify(page: Page, loginname: string) { + await page.goto("./verify"); +} + +export async function emailVerify(page: Page, code: string) { + await emailVerifyScreen(page, code); + await page.getByTestId("submit-button").click(); +} + +export async function emailVerifyResend(page: Page) { + await page.getByTestId("resend-button").click(); +} diff --git a/login/apps/login-test-acceptance/tests/idp-apple.spec.ts b/login/apps/login-test-acceptance/tests/idp-apple.spec.ts new file mode 100644 index 0000000000..32d3adba6b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-apple.spec.ts @@ -0,0 +1,102 @@ +// Note for all tests, in case Apple doesn't deliver all relevant information per default +// We should add an action in the needed cases + +import test from "@playwright/test"; + +test("login with Apple IDP", async ({ page }) => { + test.skip(); + // Given an Apple IDP is configured on the organization + // Given the user has an Apple added as auth method + // User authenticates with Apple + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Apple IDP - error", async ({ page }) => { + test.skip(); + // Given an Apple IDP is configured on the organization + // Given the user has an Apple added as auth method + // User is redirected to Apple + // User authenticates with Apple and gets an error + // User is redirect back to login + // An error is shown to the user "Something went wrong in Apple Login" +}); + +test("login with Apple IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Apple IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Apple IDP, no user linked, user link successful", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts b/login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts new file mode 100644 index 0000000000..d68475a226 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with Generic JWT IDP", async ({ page }) => { + test.skip(); + // Given a Generic JWT IDP is configured on the organization + // Given the user has Generic JWT IDP added as auth method + // User authenticates with the Generic JWT IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic JWT IDP - error", async ({ page }) => { + test.skip(); + // Given the Generic JWT IDP is configured on the organization + // Given the user has Generic JWT IDP added as auth method + // User is redirected to the Generic JWT IDP + // User authenticates with the Generic JWT IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic JWT IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic JWT IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic JWT IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts b/login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts new file mode 100644 index 0000000000..24c25d0005 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with Generic OAuth IDP", async ({ page }) => { + test.skip(); + // Given a Generic OAuth IDP is configured on the organization + // Given the user has Generic OAuth IDP added as auth method + // User authenticates with the Generic OAuth IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic OAuth IDP - error", async ({ page }) => { + test.skip(); + // Given the Generic OAuth IDP is configured on the organization + // Given the user has Generic OAuth IDP added as auth method + // User is redirected to the Generic OAuth IDP + // User authenticates with the Generic OAuth IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic OAuth IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic OAuth IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic OAuth IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts b/login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts new file mode 100644 index 0000000000..391481f99d --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts @@ -0,0 +1,101 @@ +// Note, we should use a provider such as Google to test this, where we know OIDC standard is properly implemented + +import test from "@playwright/test"; + +test("login with Generic OIDC IDP", async ({ page }) => { + test.skip(); + // Given a Generic OIDC IDP is configured on the organization + // Given the user has Generic OIDC IDP added as auth method + // User authenticates with the Generic OIDC IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic OIDC IDP - error", async ({ page }) => { + test.skip(); + // Given the Generic OIDC IDP is configured on the organization + // Given the user has Generic OIDC IDP added as auth method + // User is redirected to the Generic OIDC IDP + // User authenticates with the Generic OIDC IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic OIDC IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic OIDC IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic OIDC IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts b/login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts new file mode 100644 index 0000000000..2c39092851 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitHub Enterprise IDP", async ({ page }) => { + test.skip(); + // Given a GitHub Enterprise IDP is configured on the organization + // Given the user has GitHub Enterprise IDP added as auth method + // User authenticates with the GitHub Enterprise IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitHub Enterprise IDP - error", async ({ page }) => { + test.skip(); + // Given the GitHub Enterprise IDP is configured on the organization + // Given the user has GitHub Enterprise IDP added as auth method + // User is redirected to the GitHub Enterprise IDP + // User authenticates with the GitHub Enterprise IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with GitHub Enterprise IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with GitHub Enterprise IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-github.spec.ts b/login/apps/login-test-acceptance/tests/idp-github.spec.ts new file mode 100644 index 0000000000..689e040537 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-github.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitHub IDP", async ({ page }) => { + test.skip(); + // Given a GitHub IDP is configured on the organization + // Given the user has GitHub IDP added as auth method + // User authenticates with the GitHub IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitHub IDP - error", async ({ page }) => { + test.skip(); + // Given the GitHub IDP is configured on the organization + // Given the user has GitHub IDP added as auth method + // User is redirected to the GitHub IDP + // User authenticates with the GitHub IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with GitHub IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with GitHub IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with GitHub IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts b/login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts new file mode 100644 index 0000000000..1b05d5e19b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitLab Self-Hosted IDP", async ({ page }) => { + test.skip(); + // Given a GitLab Self-Hosted IDP is configured on the organization + // Given the user has GitLab Self-Hosted IDP added as auth method + // User authenticates with the GitLab Self-Hosted IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitLab Self-Hosted IDP - error", async ({ page }) => { + test.skip(); + // Given the GitLab Self-Hosted IDP is configured on the organization + // Given the user has GitLab Self-Hosted IDP added as auth method + // User is redirected to the GitLab Self-Hosted IDP + // User authenticates with the GitLab Self-Hosted IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Gitlab Self-Hosted IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Gitlab Self-Hosted IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts b/login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts new file mode 100644 index 0000000000..fdb235843b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitLab IDP", async ({ page }) => { + test.skip(); + // Given a GitLab IDP is configured on the organization + // Given the user has GitLab IDP added as auth method + // User authenticates with the GitLab IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitLab IDP - error", async ({ page }) => { + test.skip(); + // Given the GitLab IDP is configured on the organization + // Given the user has GitLab IDP added as auth method + // User is redirected to the GitLab IDP + // User authenticates with the GitLab IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Gitlab IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Gitlab IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Gitlab IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-google.spec.ts b/login/apps/login-test-acceptance/tests/idp-google.spec.ts new file mode 100644 index 0000000000..8eb4d54e34 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-google.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with Google IDP", async ({ page }) => { + test.skip(); + // Given a Google IDP is configured on the organization + // Given the user has Google IDP added as auth method + // User authenticates with the Google IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Google IDP - error", async ({ page }) => { + test.skip(); + // Given the Google IDP is configured on the organization + // Given the user has Google IDP added as auth method + // User is redirected to the Google IDP + // User authenticates with the Google IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Google IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Google IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Google IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-ldap.spec.ts b/login/apps/login-test-acceptance/tests/idp-ldap.spec.ts new file mode 100644 index 0000000000..0705ed45f8 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-ldap.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with LDAP IDP", async ({ page }) => { + test.skip(); + // Given a LDAP IDP is configured on the organization + // Given the user has LDAP IDP added as auth method + // User authenticates with the LDAP IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with LDAP IDP - error", async ({ page }) => { + test.skip(); + // Given the LDAP IDP is configured on the organization + // Given the user has LDAP IDP added as auth method + // User is redirected to the LDAP IDP + // User authenticates with the LDAP IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with LDAP IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with LDAP IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with LDAP IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts b/login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts new file mode 100644 index 0000000000..15d67c28aa --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts @@ -0,0 +1,102 @@ +// Note for all tests, in case Microsoft doesn't deliver all relevant information per default +// We should add an action in the needed cases + +import test from "@playwright/test"; + +test("login with Microsoft IDP", async ({ page }) => { + test.skip(); + // Given a Microsoft IDP is configured on the organization + // Given the user has Microsoft IDP added as auth method + // User authenticates with the Microsoft IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Microsoft IDP - error", async ({ page }) => { + test.skip(); + // Given the Microsoft IDP is configured on the organization + // Given the user has Microsoft IDP added as auth method + // User is redirected to the Microsoft IDP + // User authenticates with the Microsoft IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Microsoft IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Microsoft IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Microsoft IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-saml.spec.ts b/login/apps/login-test-acceptance/tests/idp-saml.spec.ts new file mode 100644 index 0000000000..90d8d618b4 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-saml.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with SAML IDP", async ({ page }) => { + test.skip(); + // Given a SAML IDP is configured on the organization + // Given the user has SAML IDP added as auth method + // User authenticates with the SAML IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with SAML IDP - error", async ({ page }) => { + test.skip(); + // Given the SAML IDP is configured on the organization + // Given the user has SAML IDP added as auth method + // User is redirected to the SAML IDP + // User authenticates with the SAML IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with SAML IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with SAML IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with SAML IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts b/login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts new file mode 100644 index 0000000000..cc58dbcc71 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts @@ -0,0 +1,57 @@ +import test from "@playwright/test"; + +test("login with mfa setup, mfa setup prompt", async ({ page }) => { + test.skip(); + // Given the organization has enabled at least one mfa types + // Given the user has a password but no mfa registered + // User authenticates with login name and password + // User is prompted to setup a mfa, mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, no mfa setup prompt", async ({ page }) => { + test.skip(); + // Given the organization has set "multifactor init check time" to 0 + // Given the organization has enabled mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // user is directly loged in and not prompted to setup mfa +}); + +test("login with mfa setup, force mfa for local authenticated users", async ({ page }) => { + test.skip(); + // Given the organization has enabled force mfa for local authentiacted users + // Given the organization has enabled all possible mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - local user", async ({ page }) => { + test.skip(); + // Given the organization has enabled force mfa for local authentiacted users + // Given the organization has enabled all possible mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - external user", async ({ page }) => { + test.skip(); + // Given the organization has enabled force mfa + // Given the organization has enabled all possible mfa types + // Given the user has an idp but no mfa registered + // enter login name + // redirect to configured external idp + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - local user, wrong password", async ({ page }) => { + test.skip(); + // Given the organization has a password lockout policy set to 1 on the max password attempts + // Given the user has only a password as auth methos + // enter login name + // enter wrong password + // User will get an error "Wrong password" + // enter password + // User will get an error "Max password attempts reached - user is locked. Please reach out to your administrator" +}); diff --git a/login/apps/login-test-acceptance/tests/login.ts b/login/apps/login-test-acceptance/tests/login.ts new file mode 100644 index 0000000000..2076412456 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/login.ts @@ -0,0 +1,41 @@ +import { expect, Page } from "@playwright/test"; +import { code, otpFromSink } from "./code"; +import { loginname } from "./loginname"; +import { password } from "./password"; +import { totp } from "./zitadel"; + +export async function startLogin(page: Page) { + await page.goto(`./loginname`); +} + +export async function loginWithPassword(page: Page, username: string, pw: string) { + await startLogin(page); + await loginname(page, username); + await password(page, pw); +} + +export async function loginWithPasskey(page: Page, authenticatorId: string, username: string) { + await startLogin(page); + await loginname(page, username); + // await passkey(page, authenticatorId); +} + +export async function loginScreenExpect(page: Page, fullName: string) { + await expect(page).toHaveURL(/.*signedin.*/); + await expect(page.getByRole("heading")).toContainText(fullName); +} + +export async function loginWithPasswordAndEmailOTP(page: Page, username: string, password: string, email: string) { + await loginWithPassword(page, username, password); + await otpFromSink(page, email); +} + +export async function loginWithPasswordAndPhoneOTP(page: Page, username: string, password: string, phone: string) { + await loginWithPassword(page, username, password); + await otpFromSink(page, phone); +} + +export async function loginWithPasswordAndTOTP(page: Page, username: string, password: string, secret: string) { + await loginWithPassword(page, username, password); + await code(page, totp(secret)); +} diff --git a/login/apps/login-test-acceptance/tests/loginname-screen.ts b/login/apps/login-test-acceptance/tests/loginname-screen.ts new file mode 100644 index 0000000000..be41a28eda --- /dev/null +++ b/login/apps/login-test-acceptance/tests/loginname-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const usernameTextInput = "username-text-input"; + +export async function loginnameScreen(page: Page, username: string) { + await page.getByTestId(usernameTextInput).pressSequentially(username); +} + +export async function loginnameScreenExpect(page: Page, username: string) { + await expect(page.getByTestId(usernameTextInput)).toHaveValue(username); + await expect(page.getByTestId("error").locator("div")).toContainText("User not found in the system"); +} diff --git a/login/apps/login-test-acceptance/tests/loginname.ts b/login/apps/login-test-acceptance/tests/loginname.ts new file mode 100644 index 0000000000..2050ec1d3c --- /dev/null +++ b/login/apps/login-test-acceptance/tests/loginname.ts @@ -0,0 +1,7 @@ +import { Page } from "@playwright/test"; +import { loginnameScreen } from "./loginname-screen"; + +export async function loginname(page: Page, username: string) { + await loginnameScreen(page, username); + await page.getByTestId("submit-button").click(); +} diff --git a/login/apps/login-test-acceptance/tests/passkey.ts b/login/apps/login-test-acceptance/tests/passkey.ts new file mode 100644 index 0000000000..d8cda10ddb --- /dev/null +++ b/login/apps/login-test-acceptance/tests/passkey.ts @@ -0,0 +1,109 @@ +import { expect, Page } from "@playwright/test"; +import { CDPSession } from "playwright-core"; + +interface session { + client: CDPSession; + authenticatorId: string; +} + +async function client(page: Page): Promise { + const cdpSession = await page.context().newCDPSession(page); + await cdpSession.send("WebAuthn.enable", { enableUI: false }); + const result = await cdpSession.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + return { client: cdpSession, authenticatorId: result.authenticatorId }; +} + +export async function passkeyRegister(page: Page): Promise { + const session = await client(page); + + await passkeyNotExisting(session.client, session.authenticatorId); + await simulateSuccessfulPasskeyRegister(session.client, session.authenticatorId, () => + page.getByTestId("submit-button").click(), + ); + await passkeyRegistered(session.client, session.authenticatorId); + + return session.authenticatorId; +} + +export async function passkey(page: Page, authenticatorId: string) { + const cdpSession = await page.context().newCDPSession(page); + await cdpSession.send("WebAuthn.enable", { enableUI: false }); + + const signCount = await passkeyExisting(cdpSession, authenticatorId); + + await simulateSuccessfulPasskeyInput(cdpSession, authenticatorId, () => page.getByTestId("submit-button").click()); + + await passkeyUsed(cdpSession, authenticatorId, signCount); +} + +async function passkeyNotExisting(client: CDPSession, authenticatorId: string) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(0); +} + +async function passkeyRegistered(client: CDPSession, authenticatorId: string) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + await passkeyUsed(client, authenticatorId, 0); +} + +async function passkeyExisting(client: CDPSession, authenticatorId: string): Promise { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + return result.credentials[0].signCount; +} + +async function passkeyUsed(client: CDPSession, authenticatorId: string, signCount: number) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0].signCount).toBeGreaterThan(signCount); +} + +async function simulateSuccessfulPasskeyRegister( + client: CDPSession, + authenticatorId: string, + operationTrigger: () => Promise, +) { + // initialize event listeners to wait for a successful passkey input event + const operationCompleted = new Promise((resolve) => { + client.on("WebAuthn.credentialAdded", () => { + console.log("Credential Added!"); + resolve(); + }); + }); + + // perform a user action that triggers passkey prompt + await operationTrigger(); + + // wait to receive the event that the passkey was successfully registered or verified + await operationCompleted; +} + +async function simulateSuccessfulPasskeyInput( + client: CDPSession, + authenticatorId: string, + operationTrigger: () => Promise, +) { + // initialize event listeners to wait for a successful passkey input event + const operationCompleted = new Promise((resolve) => { + client.on("WebAuthn.credentialAsserted", () => { + console.log("Credential Asserted!"); + resolve(); + }); + }); + + // perform a user action that triggers passkey prompt + await operationTrigger(); + + // wait to receive the event that the passkey was successfully registered or verified + await operationCompleted; +} diff --git a/login/apps/login-test-acceptance/tests/password-screen.ts b/login/apps/login-test-acceptance/tests/password-screen.ts new file mode 100644 index 0000000000..fda6f6d39f --- /dev/null +++ b/login/apps/login-test-acceptance/tests/password-screen.ts @@ -0,0 +1,98 @@ +import { expect, Page } from "@playwright/test"; +import { getCodeFromSink } from "./sink"; + +const codeField = "code-text-input"; +const passwordField = "password-text-input"; +const passwordChangeField = "password-change-text-input"; +const passwordChangeConfirmField = "password-change-confirm-text-input"; +const passwordSetField = "password-set-text-input"; +const passwordSetConfirmField = "password-set-confirm-text-input"; +const lengthCheck = "length-check"; +const symbolCheck = "symbol-check"; +const numberCheck = "number-check"; +const uppercaseCheck = "uppercase-check"; +const lowercaseCheck = "lowercase-check"; +const equalCheck = "equal-check"; + +const matchText = "Matches"; +const noMatchText = "Doesn't match"; + +export async function changePasswordScreen(page: Page, password1: string, password2: string) { + await page.getByTestId(passwordChangeField).pressSequentially(password1); + await page.getByTestId(passwordChangeConfirmField).pressSequentially(password2); +} + +export async function passwordScreen(page: Page, password: string) { + await page.getByTestId(passwordField).pressSequentially(password); +} + +export async function passwordScreenExpect(page: Page, password: string) { + await expect(page.getByTestId(passwordField)).toHaveValue(password); + await expect(page.getByTestId("error").locator("div")).toContainText("Failed to authenticate."); +} + +export async function changePasswordScreenExpect( + page: Page, + password1: string, + password2: string, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await expect(page.getByTestId(passwordChangeField)).toHaveValue(password1); + await expect(page.getByTestId(passwordChangeConfirmField)).toHaveValue(password2); + + await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals); +} + +async function checkComplexity( + page: Page, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await checkContent(page, lengthCheck, length); + await checkContent(page, symbolCheck, symbol); + await checkContent(page, numberCheck, number); + await checkContent(page, uppercaseCheck, uppercase); + await checkContent(page, lowercaseCheck, lowercase); + await checkContent(page, equalCheck, equals); +} + +async function checkContent(page: Page, testid: string, match: boolean) { + if (match) { + await expect(page.getByTestId(testid)).toContainText(matchText); + } else { + await expect(page.getByTestId(testid)).toContainText(noMatchText); + } +} + +export async function resetPasswordScreen(page: Page, username: string, password1: string, password2: string) { + const c = await getCodeFromSink(username); + await page.getByTestId(codeField).pressSequentially(c); + await page.getByTestId(passwordSetField).pressSequentially(password1); + await page.getByTestId(passwordSetConfirmField).pressSequentially(password2); +} + +export async function resetPasswordScreenExpect( + page: Page, + password1: string, + password2: string, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await expect(page.getByTestId(passwordSetField)).toHaveValue(password1); + await expect(page.getByTestId(passwordSetConfirmField)).toHaveValue(password2); + + await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals); +} diff --git a/login/apps/login-test-acceptance/tests/password.ts b/login/apps/login-test-acceptance/tests/password.ts new file mode 100644 index 0000000000..ccf3e509d9 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/password.ts @@ -0,0 +1,29 @@ +import { Page } from "@playwright/test"; +import { changePasswordScreen, passwordScreen, resetPasswordScreen } from "./password-screen"; + +const passwordSubmitButton = "submit-button"; +const passwordResetButton = "reset-button"; + +export async function startChangePassword(page: Page, loginname: string) { + await page.goto("./password/change?" + new URLSearchParams({ loginName: loginname })); +} + +export async function changePassword(page: Page, password: string) { + await changePasswordScreen(page, password, password); + await page.getByTestId(passwordSubmitButton).click(); +} + +export async function password(page: Page, password: string) { + await passwordScreen(page, password); + await page.getByTestId(passwordSubmitButton).click(); +} + +export async function startResetPassword(page: Page) { + await page.getByTestId(passwordResetButton).click(); +} + +export async function resetPassword(page: Page, username: string, password: string) { + await startResetPassword(page); + await resetPasswordScreen(page, username, password, password); + await page.getByTestId(passwordSubmitButton).click(); +} diff --git a/login/apps/login-test-acceptance/tests/register-screen.ts b/login/apps/login-test-acceptance/tests/register-screen.ts new file mode 100644 index 0000000000..d14f5dc970 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/register-screen.ts @@ -0,0 +1,27 @@ +import { Page } from "@playwright/test"; + +const passwordField = "password-text-input"; +const passwordConfirmField = "password-confirm-text-input"; + +export async function registerUserScreenPassword(page: Page, firstname: string, lastname: string, email: string) { + await registerUserScreen(page, firstname, lastname, email); + await page.getByTestId("password-radio").click(); +} + +export async function registerUserScreenPasskey(page: Page, firstname: string, lastname: string, email: string) { + await registerUserScreen(page, firstname, lastname, email); + await page.getByTestId("passkey-radio").click(); +} + +export async function registerPasswordScreen(page: Page, password1: string, password2: string) { + await page.getByTestId(passwordField).pressSequentially(password1); + await page.getByTestId(passwordConfirmField).pressSequentially(password2); +} + +export async function registerUserScreen(page: Page, firstname: string, lastname: string, email: string) { + await page.getByTestId("firstname-text-input").pressSequentially(firstname); + await page.getByTestId("lastname-text-input").pressSequentially(lastname); + await page.getByTestId("email-text-input").pressSequentially(email); + await page.getByTestId("privacy-policy-checkbox").check(); + await page.getByTestId("tos-checkbox").check(); +} diff --git a/login/apps/login-test-acceptance/tests/register.spec.ts b/login/apps/login-test-acceptance/tests/register.spec.ts new file mode 100644 index 0000000000..4ad7e9e349 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/register.spec.ts @@ -0,0 +1,183 @@ +import { faker } from "@faker-js/faker"; +import { test } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect } from "./login"; +import { registerWithPasskey, registerWithPassword } from "./register"; +import { removeUserByUsername } from "./zitadel"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +test("register with password", async ({ page }) => { + const username = faker.internet.email(); + const password = "Password1!"; + const firstname = faker.person.firstName(); + const lastname = faker.person.lastName(); + + await registerWithPassword(page, firstname, lastname, username, password, password); + await loginScreenExpect(page, firstname + " " + lastname); + + // wait for projection of user + await page.waitForTimeout(10000); + await removeUserByUsername(username); +}); + +test("register with passkey", async ({ page }) => { + const username = faker.internet.email(); + const firstname = faker.person.firstName(); + const lastname = faker.person.lastName(); + + await registerWithPasskey(page, firstname, lastname, username); + await loginScreenExpect(page, firstname + " " + lastname); + + // wait for projection of user + await page.waitForTimeout(10000); + await removeUserByUsername(username); +}); + +test("register with username and password - only password enabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and "password" + // User is redirected to app (default redirect url) +}); + +test("register with username and password - wrong password not enough characters", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password thats to short + // Error is shown "Password doesn't match the policy - it must have at least 8 characters" +}); + +test("register with username and password - wrong password number missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without a number + // Error is shown "Password doesn't match the policy - number missing" +}); + +test("register with username and password - wrong password upper case missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an upper case + // Error is shown "Password doesn't match the policy - uppercase letter missing" +}); + +test("register with username and password - wrong password lower case missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an lower case + // Error is shown "Password doesn't match the policy - lowercase letter missing" +}); + +test("register with username and password - wrong password symboo missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an symbol + // Error is shown "Password doesn't match the policy - symbol missing" +}); + +test("register with username and password - password and passkey enabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // User enters "firstname", "lastname", "username" + // Password and passkey are shown as authentication option + // User clicks password + // User enters password + // User is redirected to app (default redirect url) +}); + +test("register with username and passkey - password and passkey enabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // User enters "firstname", "lastname", "username" + // Password and passkey are shown as authentication option + // User clicks passkey + // Passkey is opened automatically + // User verifies passkey + // User is redirected to app (default redirect url) +}); + +test("register with username and password - registration disabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given user doesn't exist + // Button "register new user" is not available +}); + +test("register with username and password - multiple registration options", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization one idp is configured and enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration options + // Local User and idp button are shown + // User clicks idp button + // User enters "firstname", "lastname", "username" and "password" + // User clicks next + // User is redirected to app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/register.ts b/login/apps/login-test-acceptance/tests/register.ts new file mode 100644 index 0000000000..164a72753b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/register.ts @@ -0,0 +1,39 @@ +import { Page } from "@playwright/test"; +import { emailVerify } from "./email-verify"; +import { passkeyRegister } from "./passkey"; +import { registerPasswordScreen, registerUserScreenPasskey, registerUserScreenPassword } from "./register-screen"; +import { getCodeFromSink } from "./sink"; + +export async function registerWithPassword( + page: Page, + firstname: string, + lastname: string, + email: string, + password1: string, + password2: string, +) { + await page.goto("./register"); + await registerUserScreenPassword(page, firstname, lastname, email); + await page.getByTestId("submit-button").click(); + await registerPasswordScreen(page, password1, password2); + await page.getByTestId("submit-button").click(); + await verifyEmail(page, email); +} + +export async function registerWithPasskey(page: Page, firstname: string, lastname: string, email: string): Promise { + await page.goto("./register"); + await registerUserScreenPasskey(page, firstname, lastname, email); + await page.getByTestId("submit-button").click(); + + // wait for projection of user + await page.waitForTimeout(10000); + const authId = await passkeyRegister(page); + + await verifyEmail(page, email); + return authId; +} + +async function verifyEmail(page: Page, email: string) { + const c = await getCodeFromSink(email); + await emailVerify(page, c); +} diff --git a/login/apps/login-test-acceptance/tests/select-account.ts b/login/apps/login-test-acceptance/tests/select-account.ts new file mode 100644 index 0000000000..64bd7cd145 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/select-account.ts @@ -0,0 +1,5 @@ +import { Page } from "@playwright/test"; + +export async function selectNewAccount(page: Page) { + await page.getByRole("link", { name: "Add another account" }).click(); +} diff --git a/login/apps/login-test-acceptance/tests/sink.ts b/login/apps/login-test-acceptance/tests/sink.ts new file mode 100644 index 0000000000..bc3336b358 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/sink.ts @@ -0,0 +1,43 @@ +import { Gaxios, GaxiosResponse } from "gaxios"; + +const awaitNotification = new Gaxios({ + url: process.env.SINK_NOTIFICATION_URL, + method: "POST", + retryConfig: { + httpMethodsToRetry: ["POST"], + statusCodesToRetry: [[404, 404]], + retry: Number.MAX_SAFE_INTEGER, // totalTimeout limits the number of retries + totalTimeout: 10000, // 10 seconds + onRetryAttempt: (error) => { + console.warn(`Retrying request to sink notification service: ${error.message}`); + }, + }, +}); + +export async function getOtpFromSink(recipient: string): Promise { + return awaitNotification.request({ data: { recipient } }).then((response) => { + expectSuccess(response); + const otp = response?.data?.args?.otp; + if (!otp) { + throw new Error(`Response does not contain an otp property: ${JSON.stringify(response.data, null, 2)}`); + } + return otp; + }); +} + +export async function getCodeFromSink(recipient: string): Promise { + return awaitNotification.request({ data: { recipient } }).then((response) => { + expectSuccess(response); + const code = response?.data?.args?.code; + if (!code) { + throw new Error(`Response does not contain a code property: ${JSON.stringify(response.data, null, 2)}`); + } + return code; + }); +} + +function expectSuccess(response: GaxiosResponse): void { + if (response.status !== 200) { + throw new Error(`Expected HTTP status 200, but got: ${response.status} - ${response.statusText}`); + } +} diff --git a/login/apps/login-test-acceptance/tests/user.ts b/login/apps/login-test-acceptance/tests/user.ts new file mode 100644 index 0000000000..3b03291408 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/user.ts @@ -0,0 +1,177 @@ +import { Page } from "@playwright/test"; +import { registerWithPasskey } from "./register"; +import { activateOTP, addTOTP, addUser, eventualNewUser, getUserByUsername, removeUser } from "./zitadel"; + +export interface userProps { + email: string; + isEmailVerified?: boolean; + firstName: string; + lastName: string; + organization: string; + password: string; + passwordChangeRequired?: boolean; + phone: string; + isPhoneVerified?: boolean; +} + +class User { + private readonly props: userProps; + private user: string; + + constructor(userProps: userProps) { + this.props = userProps; + } + + async ensure(page: Page) { + const response = await addUser(this.props); + + this.setUserId(response.userId); + } + + async cleanup() { + await removeUser(this.getUserId()); + } + + public setUserId(userId: string) { + this.user = userId; + } + + public getUserId() { + return this.user; + } + + public getUsername() { + return this.props.email; + } + + public getPassword() { + return this.props.password; + } + + public getFirstname() { + return this.props.firstName; + } + + public getLastname() { + return this.props.lastName; + } + + public getPhone() { + return this.props.phone; + } + + public getFullName() { + return `${this.props.firstName} ${this.props.lastName}`; + } +} + +export class PasswordUser extends User { + async ensure(page: Page) { + await super.ensure(page); + await eventualNewUser(this.getUserId()); + } +} + +export enum OtpType { + sms = "sms", + email = "email", +} + +export interface otpUserProps { + email: string; + isEmailVerified?: boolean; + firstName: string; + lastName: string; + organization: string; + password: string; + passwordChangeRequired?: boolean; + phone: string; + isPhoneVerified?: boolean; + type: OtpType; +} + +export class PasswordUserWithOTP extends User { + private type: OtpType; + + constructor(props: otpUserProps) { + super({ + email: props.email, + firstName: props.firstName, + lastName: props.lastName, + organization: props.organization, + password: props.password, + phone: props.phone, + isEmailVerified: props.isEmailVerified, + isPhoneVerified: props.isPhoneVerified, + passwordChangeRequired: props.passwordChangeRequired, + }); + this.type = props.type; + } + + async ensure(page: Page) { + await super.ensure(page); + await activateOTP(this.getUserId(), this.type); + await eventualNewUser(this.getUserId()); + } +} + +export class PasswordUserWithTOTP extends User { + private secret: string; + + async ensure(page: Page) { + await super.ensure(page); + this.secret = await addTOTP(this.getUserId()); + await eventualNewUser(this.getUserId()); + } + + public getSecret(): string { + return this.secret; + } +} + +export interface passkeyUserProps { + email: string; + firstName: string; + lastName: string; + organization: string; + phone: string; + isEmailVerified?: boolean; + isPhoneVerified?: boolean; +} + +export class PasskeyUser extends User { + private authenticatorId: string; + + constructor(props: passkeyUserProps) { + super({ + email: props.email, + firstName: props.firstName, + lastName: props.lastName, + organization: props.organization, + password: "", + phone: props.phone, + isEmailVerified: props.isEmailVerified, + isPhoneVerified: props.isPhoneVerified, + }); + } + + public async ensure(page: Page) { + const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername()); + this.authenticatorId = authId; + + // wait for projection of user + await page.waitForTimeout(10000); + } + + async cleanup() { + const resp: any = await getUserByUsername(this.getUsername()); + if (!resp || !resp.result || !resp.result[0]) { + return; + } + await removeUser(resp.result[0].userId); + } + + public getAuthenticatorId(): string { + return this.authenticatorId; + } +} diff --git a/login/apps/login-test-acceptance/tests/username-passkey.spec.ts b/login/apps/login-test-acceptance/tests/username-passkey.spec.ts new file mode 100644 index 0000000000..dff1c65f5a --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-passkey.spec.ts @@ -0,0 +1,43 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPasskey } from "./login"; +import { PasskeyUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasskeyUser }>({ + user: async ({ page }, use) => { + const user = new PasskeyUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and passkey login", async ({ user, page }) => { + await loginWithPasskey(page, user.getAuthenticatorId(), user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username and passkey login, multiple auth methods", async ({ page }) => { + test.skip(); + // Given passkey and password is enabled on the organization of the user + // Given the user has password and passkey registered + // enter username + // passkey popup is directly shown + // user aborts passkey authentication + // user switches to password authentication + // user enters password + // user is redirected to app +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts b/login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts new file mode 100644 index 0000000000..50605e5ff0 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts @@ -0,0 +1,41 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { changePassword } from "./password"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: true, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password login, change required", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await page.waitForTimeout(10000); + await changePassword(page, changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-changed.spec.ts b/login/apps/login-test-acceptance/tests/username-password-changed.spec.ts new file mode 100644 index 0000000000..dc29dc2286 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-changed.spec.ts @@ -0,0 +1,54 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { changePassword, startChangePassword } from "./password"; +import { changePasswordScreen, changePasswordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password changed login", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + + // wait for projection of token + await page.waitForTimeout(10000); + + await startChangePassword(page, user.getUsername()); + await changePassword(page, changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); + +test("password change not with desired complexity", async ({ user, page }) => { + const changedPw1 = "change"; + const changedPw2 = "chang"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await startChangePassword(page, user.getUsername()); + await changePasswordScreen(page, changedPw1, changedPw2); + await changePasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts b/login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts new file mode 100644 index 0000000000..e4a77751c1 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts @@ -0,0 +1,98 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code, codeResend, otpFromSink } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndEmailOTP } from "./login"; +import { OtpType, PasswordUserWithOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + type: OtpType.email, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test.skip("DOESN'T WORK: username, password and email otp login, enter code manually", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndEmailOTP(page, user.getUsername(), user.getPassword(), user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and email otp login, click link in email", async ({ page }) => { + base.skip(); + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks link in the email + // User is redirected to the app (default redirect url) +}); + +test.skip("DOESN'T WORK: username, password and email otp login, resend code", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks resend code + // User receives a new email with a verification code + // User enters the new code in the ui + // User is redirected to the app (default redirect url) + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await codeResend(page); + await otpFromSink(page, user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and email otp login, wrong code", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User enters a wrong code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); + +test("username, password and email otp login, multiple mfa options", async ({ page }) => { + base.skip(); + // Given email otp and sms otp is enabled on the organization of the user + // Given the user has email and sms otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks button to use sms otp as second factor + // User receives a sms with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts b/login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts new file mode 100644 index 0000000000..10901cd243 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts @@ -0,0 +1,71 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndPhoneOTP } from "./login"; +import { OtpType, PasswordUserWithOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number({ style: "international" }), + isPhoneVerified: true, + password: "Password1!", + passwordChangeRequired: false, + type: OtpType.sms, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test.skip("DOESN'T WORK: username, password and sms otp login, enter code manually", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone()); + await loginScreenExpect(page, user.getFullName()); +}); + +test.skip("DOESN'T WORK: username, password and sms otp login, resend code", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User clicks resend code + // User receives a new sms with a verification code + // User is redirected to the app (default redirect url) + await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and sms otp login, wrong code", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User enters a wrong code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-set.spec.ts b/login/apps/login-test-acceptance/tests/username-password-set.spec.ts new file mode 100644 index 0000000000..06ce42f1a7 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-set.spec.ts @@ -0,0 +1,52 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword, startLogin } from "./login"; +import { loginname } from "./loginname"; +import { resetPassword, startResetPassword } from "./password"; +import { resetPasswordScreen, resetPasswordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password set login", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + await startLogin(page); + await loginname(page, user.getUsername()); + await resetPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); + +test("password set not with desired complexity", async ({ user, page }) => { + const changedPw1 = "change"; + const changedPw2 = "chang"; + await startLogin(page); + await loginname(page, user.getUsername()); + await startResetPassword(page); + await resetPasswordScreen(page, user.getUsername(), changedPw1, changedPw2); + await resetPasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-totp.spec.ts b/login/apps/login-test-acceptance/tests/username-password-totp.spec.ts new file mode 100644 index 0000000000..e495b16681 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-totp.spec.ts @@ -0,0 +1,71 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndTOTP } from "./login"; +import { PasswordUserWithTOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUserWithTOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithTOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number({ style: "international" }), + isPhoneVerified: true, + password: "Password1!", + passwordChangeRequired: false, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username, password and totp login", async ({ user, page }) => { + // Given totp is enabled on the organization of the user + // Given the user has only totp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndTOTP(page, user.getUsername(), user.getPassword(), user.getSecret()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and totp otp login, wrong code", async ({ user, page }) => { + // Given totp is enabled on the organization of the user + // Given the user has only totp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // User enters a wrond code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); + +test("username, password and totp login, multiple mfa options", async ({ page }) => { + test.skip(); + // Given totp and email otp is enabled on the organization of the user + // Given the user has totp and email otp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // Button to switch to email otp is shown + // User clicks button to use email otp instead + // User receives an email with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts b/login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts new file mode 100644 index 0000000000..dc23064fd6 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts @@ -0,0 +1,26 @@ +import { test } from "@playwright/test"; + +test("username, password and u2f login", async ({ page }) => { + test.skip(); + // Given u2f is enabled on the organization of the user + // Given the user has only u2f configured as second factor + // User enters username + // User enters password + // Popup for u2f is directly opened + // User verifies u2f + // User is redirected to the app (default redirect url) +}); + +test("username, password and u2f login, multiple mfa options", async ({ page }) => { + test.skip(); + // Given u2f and semailms otp is enabled on the organization of the user + // Given the user has u2f and email otp configured as second factor + // User enters username + // User enters password + // Popup for u2f is directly opened + // User aborts u2f verification + // User clicks button to use email otp as second factor + // User receives an email with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/username-password.spec.ts b/login/apps/login-test-acceptance/tests/username-password.spec.ts new file mode 100644 index 0000000000..ceb340f8da --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password.spec.ts @@ -0,0 +1,157 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword, startLogin } from "./login"; +import { loginname } from "./loginname"; +import { loginnameScreenExpect } from "./loginname-screen"; +import { password } from "./password"; +import { passwordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password login", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username and password login, unknown username", async ({ page }) => { + const username = "unknown"; + await startLogin(page); + await loginname(page, username); + await loginnameScreenExpect(page, username); +}); + +test("username and password login, wrong password", async ({ user, page }) => { + await startLogin(page); + await loginname(page, user.getUsername()); + await password(page, "wrong"); + await passwordScreenExpect(page, "wrong"); +}); + +test("username and password login, wrong username, ignore unknown usernames", async ({ user, page }) => { + test.skip(); + // Given user doesn't exist but ignore unknown usernames setting is set to true + // Given username password login is enabled on the users organization + // enter login name + // enter password + // redirect to loginname page --> error message username or password wrong +}); + +test("username and password login, initial password change", async ({ user, page }) => { + test.skip(); + // Given user is created and has changePassword set to true + // Given username password login is enabled on the users organization + // enter login name + // enter password + // create new password +}); + +test("username and password login, reset password hidden", async ({ user, page }) => { + test.skip(); + // Given the organization has enabled "Password reset hidden" in the login policy + // Given username password login is enabled on the users organization + // enter login name + // password reset link should not be shown on password screen +}); + +test("username and password login, reset password - enter code manually", async ({ user, page }) => { + test.skip(); + // Given user has forgotten password and clicks the forgot password button + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // enter code from email + // user is redirected to app (default redirect url) +}); + +test("username and password login, reset password - click link", async ({ user, page }) => { + test.skip(); + // Given user has forgotten password and clicks the forgot password button, and then the link in the email + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // click link in email + // set new password + // redirect to app (default redirect url) +}); + +test("username and password login, reset password, resend code", async ({ user, page }) => { + test.skip(); + // Given user has forgotten password and clicks the forgot password button and then resend code + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // click resend code + // enter code from second email + // user is redirected to app (default redirect url) +}); + +test("email login enabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same email address exists + // enter email address "test@zitadel.com " in login screen + // user will get to password screen +}); + +test("email login disabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same email address exists + // enter email address "test@zitadel.com" in login screen + // user will see error message "user not found" +}); + +test("email login enabled - multiple users", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given a second user with the username "testuser2", email test@zitadel.com and phone number 0711111111 exists + // enter email address "test@zitadel.com" in login screen + // user will see error message "user not found" +}); + +test("phone login enabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same phon number exists + // enter phone number "0711111111" in login screen + // user will get to password screen +}); + +test("phone login disabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same phone number exists + // enter phone number "0711111111" in login screen + // user will see error message "user not found" +}); + +test("phone login enabled - multiple users", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given a second user with the username "testuser2", email test@zitadel.com and phone number 0711111111 exists + // enter phone number "0711111111" in login screen + // user will see error message "user not found" +}); diff --git a/login/apps/login-test-acceptance/tests/welcome.ts b/login/apps/login-test-acceptance/tests/welcome.ts new file mode 100644 index 0000000000..34267c2bd0 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/welcome.ts @@ -0,0 +1,6 @@ +import { test } from "@playwright/test"; + +test("login is accessible", async ({ page }) => { + await page.goto("./"); + await page.getByRole("heading", { name: "Welcome back!" }).isVisible(); +}); diff --git a/login/apps/login-test-acceptance/tests/zitadel.ts b/login/apps/login-test-acceptance/tests/zitadel.ts new file mode 100644 index 0000000000..b252654f86 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/zitadel.ts @@ -0,0 +1,190 @@ +import { Authenticator } from "@otplib/core"; +import { createDigest, createRandomBytes } from "@otplib/plugin-crypto"; +import { keyDecoder, keyEncoder } from "@otplib/plugin-thirty-two"; // use your chosen base32 plugin +import axios from "axios"; +import dotenv from "dotenv"; +import { request } from "gaxios"; +import path from "path"; +import { OtpType, userProps } from "./user"; + +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +export async function addUser(props: userProps) { + const body = { + username: props.email, + organization: { + orgId: props.organization, + }, + profile: { + givenName: props.firstName, + familyName: props.lastName, + }, + email: { + email: props.email, + isVerified: true, + }, + phone: { + phone: props.phone, + isVerified: true, + }, + password: { + password: props.password, + changeRequired: props.passwordChangeRequired ?? false, + }, + }; + if (!props.isEmailVerified) { + delete body.email.isVerified; + } + if (!props.isPhoneVerified) { + delete body.phone.isVerified; + } + + return await listCall(`${process.env.ZITADEL_API_URL}/v2/users/human`, body); +} + +export async function removeUserByUsername(username: string) { + const resp = await getUserByUsername(username); + if (!resp || !resp.result || !resp.result[0]) { + return; + } + await removeUser(resp.result[0].userId); +} + +export async function removeUser(id: string) { + await deleteCall(`${process.env.ZITADEL_API_URL}/v2/users/${id}`); +} + +async function deleteCall(url: string) { + try { + const response = await axios.delete(url, { + headers: { + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400 && response.status !== 404) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function getUserByUsername(username: string): Promise { + const listUsersBody = { + queries: [ + { + userNameQuery: { + userName: username, + }, + }, + ], + }; + + return await listCall(`${process.env.ZITADEL_API_URL}/v2/users`, listUsersBody); +} + +async function listCall(url: string, data: any): Promise { + try { + const response = await axios.post(url, data, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + + return response.data; + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function activateOTP(userId: string, type: OtpType) { + let url = "otp_"; + switch (type) { + case OtpType.sms: + url = url + "sms"; + break; + case OtpType.email: + url = url + "email"; + break; + } + + await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/${url}`, {}); +} + +async function pushCall(url: string, data: any) { + try { + const response = await axios.post(url, data, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function addTOTP(userId: string): Promise { + const response = await listCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp`, {}); + const code = totp(response.secret); + await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp/verify`, { code: code }); + return response.secret; +} + +export function totp(secret: string) { + const authenticator = new Authenticator({ + createDigest, + createRandomBytes, + keyDecoder, + keyEncoder, + }); + // google authenticator usage + const token = authenticator.generate(secret); + + // check if token can be used + if (!authenticator.verify({ token: token, secret: secret })) { + const error = `Generated token could not be verified`; + console.error(error); + throw new Error(error); + } + + return token; +} + +export async function eventualNewUser(id: string) { + return request({ + url: `${process.env.ZITADEL_API_URL}/v2/users/${id}`, + method: "GET", + headers: { + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + "Content-Type": "application/json", + }, + retryConfig: { + statusCodesToRetry: [[404, 404]], + retry: Number.MAX_SAFE_INTEGER, // totalTimeout limits the number of retries + totalTimeout: 10000, // 10 seconds + onRetryAttempt: (error) => { + console.warn(`Retrying to query new user ${id}: ${error.message}`); + }, + }, + }); +} diff --git a/login/apps/login-test-acceptance/turbo.json b/login/apps/login-test-acceptance/turbo.json new file mode 100644 index 0000000000..3be0539d0f --- /dev/null +++ b/login/apps/login-test-acceptance/turbo.json @@ -0,0 +1,10 @@ +{ + "extends": ["//"], + "tasks": { + "test:acceptance:setup:dev": { + "interactive": true, + "cache": false, + "persistent": true + } + } +} diff --git a/login/apps/login-test-acceptance/zitadel.yaml b/login/apps/login-test-acceptance/zitadel.yaml new file mode 100644 index 0000000000..3ddeaf67f0 --- /dev/null +++ b/login/apps/login-test-acceptance/zitadel.yaml @@ -0,0 +1,83 @@ +ExternalDomain: 127.0.0.1.sslip.io +ExternalSecure: true +ExternalPort: 443 +TLS.Enabled: false + +FirstInstance: + PatPath: /pat/zitadel-admin-sa.pat + Org: + Human: + UserName: zitadel-admin + FirstName: ZITADEL + LastName: Admin + Password: Password1! + PasswordChangeRequired: false + PreferredLanguage: en + Machine: + Machine: + Username: zitadel-admin-sa + Name: Admin + Pat: + ExpirationDate: 2099-01-01T00:00:00Z + +DefaultInstance: + LoginPolicy: + AllowUsernamePassword: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWUSERNAMEPASSWORD + AllowRegister: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWREGISTER + AllowExternalIDP: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWEXTERNALIDP + ForceMFA: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_FORCEMFA + HidePasswordReset: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_HIDEPASSWORDRESET + IgnoreUnknownUsernames: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_IGNOREUNKNOWNUSERNAMES + AllowDomainDiscovery: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWDOMAINDISCOVERY + # 1 is allowed, 0 is not allowed + PasswordlessType: 1 # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_PASSWORDLESSTYPE + # DefaultRedirectURL is empty by default because we use the Console UI + DefaultRedirectURI: # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_DEFAULTREDIRECTURI + # 240h = 10d + PasswordCheckLifetime: 240h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_PASSWORDCHECKLIFETIME + # 240h = 10d + ExternalLoginCheckLifetime: 240h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_EXTERNALLOGINCHECKLIFETIME + # 720h = 30d + MfaInitSkipLifetime: 0h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_MFAINITSKIPLIFETIME + SecondFactorCheckLifetime: 18h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_SECONDFACTORCHECKLIFETIME + MultiFactorCheckLifetime: 12h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_MULTIFACTORCHECKLIFETIME + PrivacyPolicy: + TOSLink: "https://zitadel.com/docs/legal/terms-of-service" + PrivacyLink: "https://zitadel.com/docs/legal/policies/privacy-policy" + HelpLink: "https://zitadel.com/docs" + SupportEmail: "support@zitadel.com" + DocsLink: "https://zitadel.com/docs" + Features: + LoginV2: + Required: true + +OIDC: + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" + +SAML: + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" + +Database: + EventPushConnRatio: 0.2 # 4 + ProjectionSpoolerConnRatio: 0.3 # 6 + postgres: + Host: db + Port: 5432 + Database: zitadel + MaxOpenConns: 20 + MaxIdleConns: 20 + MaxConnLifetime: 1h + MaxConnIdleTime: 5m + User: + Username: zitadel + SSL: + Mode: disable + Admin: + Username: zitadel + SSL: + Mode: disable + +Logstore: + Access: + Stdout: + Enabled: true diff --git a/login/apps/login-test-integration/.gitignore b/login/apps/login-test-integration/.gitignore new file mode 100644 index 0000000000..2ca81ab137 --- /dev/null +++ b/login/apps/login-test-integration/.gitignore @@ -0,0 +1,2 @@ +screenshots +videos \ No newline at end of file diff --git a/login/apps/login-test-integration/core-mock/Dockerfile b/login/apps/login-test-integration/core-mock/Dockerfile new file mode 100644 index 0000000000..469147d17d --- /dev/null +++ b/login/apps/login-test-integration/core-mock/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.20.5-alpine3.18 + +RUN go install github.com/eliobischof/grpc-mock/cmd/grpc-mock@01b09f60db1b501178af59bed03b2c22661df48c + +COPY mocked-services.cfg . +COPY initial-stubs initial-stubs +COPY --from=protos . . + +ENTRYPOINT [ "sh", "-c", "grpc-mock -v 1 -proto $(tr '\n' ',' < ./mocked-services.cfg) -stub-dir ./initial-stubs -mock-addr :22222" ] diff --git a/login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json b/login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json new file mode 100644 index 0000000000..3da4ae999f --- /dev/null +++ b/login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json @@ -0,0 +1,59 @@ +[ + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetBrandingSettings", + "out": { + "data": {} + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetSecuritySettings", + "out": { + "data": {} + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetLegalAndSupportSettings", + "out": { + "data": { + "settings": { + "tosLink": "http://whatever.com/help", + "privacyPolicyLink": "http://whatever.com/help", + "helpLink": "http://whatever.com/help" + } + } + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetActiveIdentityProviders", + "out": { + "data": { + "identityProviders": [ + { + "id": "123", + "name": "Hubba bubba", + "type": 10 + } + ] + } + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetPasswordComplexitySettings", + "out": { + "data": { + "settings": { + "minLength": 8, + "requiresUppercase": true, + "requiresLowercase": true, + "requiresNumber": true, + "requiresSymbol": true + } + } + } + } +] diff --git a/login/apps/login-test-integration/core-mock/mocked-services.cfg b/login/apps/login-test-integration/core-mock/mocked-services.cfg new file mode 100644 index 0000000000..6a758ab8c1 --- /dev/null +++ b/login/apps/login-test-integration/core-mock/mocked-services.cfg @@ -0,0 +1,7 @@ +zitadel/user/v2/user_service.proto +zitadel/org/v2/org_service.proto +zitadel/session/v2/session_service.proto +zitadel/settings/v2/settings_service.proto +zitadel/management.proto +zitadel/auth.proto +zitadel/admin.proto \ No newline at end of file diff --git a/login/apps/login-test-integration/cypress.config.ts b/login/apps/login-test-integration/cypress.config.ts new file mode 100644 index 0000000000..080cb31bc6 --- /dev/null +++ b/login/apps/login-test-integration/cypress.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + reporter: "list", + + e2e: { + baseUrl: process.env.LOGIN_BASE_URL || "http://localhost:3000", + specPattern: "integration/**/*.cy.{js,jsx,ts,tsx}", + supportFile: "support/e2e.{js,jsx,ts,tsx}", + setupNodeEvents(on, config) { + // implement node event listeners here + }, + }, +}); diff --git a/login/apps/login-test-integration/docker-compose.yaml b/login/apps/login-test-integration/docker-compose.yaml new file mode 100644 index 0000000000..2f09a2253e --- /dev/null +++ b/login/apps/login-test-integration/docker-compose.yaml @@ -0,0 +1,30 @@ +services: + core-mock: + image: "${LOGIN_CORE_MOCK_TAG:-login-core-mock:local}" + container_name: integration-core-mock + ports: + - 22220:22220 + - 22222:22222 + + login: + image: "${LOGIN_TAG:-login:local}" + container_name: integration-login + ports: + - 3001:3001 + environment: + - PORT=3001 + - ZITADEL_API_URL=http://core-mock:22222 + - ZITADEL_SERVICE_USER_TOKEN="yolo" + - EMAIL_VERIFICATION=true + + integration: + image: "${LOGIN_TEST_INTEGRATION_TAG:-login-test-integration:local}" + container_name: integration + environment: + - LOGIN_BASE_URL=http://login:3001/ui/v2/login + - CYPRESS_CORE_MOCK_STUBS_URL=http://core-mock:22220/v1/stubs + depends_on: + login: + condition: service_started + core-mock: + condition: service_started diff --git a/login/apps/login-test-integration/fixtures/example.json b/login/apps/login-test-integration/fixtures/example.json new file mode 100644 index 0000000000..02e4254378 --- /dev/null +++ b/login/apps/login-test-integration/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/login/apps/login-test-integration/integration/invite.cy.ts b/login/apps/login-test-integration/integration/invite.cy.ts new file mode 100644 index 0000000000..a68ff96c36 --- /dev/null +++ b/login/apps/login-test-integration/integration/invite.cy.ts @@ -0,0 +1,110 @@ +import { stub } from "../support/e2e"; + +describe("verify invite", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [], // user with no auth methods was invited + }, + }); + + stub("zitadel.user.v2.UserService", "GetUserByID", { + data: { + user: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: false, + }, + }, + }, + }, + }); + + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowUsernamePassword: true, + }, + }, + }); + }); + + it.only("shows authenticators after successful invite verification", () => { + stub("zitadel.user.v2.UserService", "VerifyInviteCode"); + + cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/authenticator/set"); + }); + + it("shows an error if invite code validation failed", () => { + stub("zitadel.user.v2.UserService", "VerifyInviteCode", { + code: 3, + error: "error validating code", + }); + + // TODO: Avoid uncaught exception in application + cy.once("uncaught:exception", () => false); + cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.contains("Could not verify invite", { timeout: 10_000 }); + }); +}); diff --git a/login/apps/login-test-integration/integration/login.cy.ts b/login/apps/login-test-integration/integration/login.cy.ts new file mode 100644 index 0000000000..917d719cb1 --- /dev/null +++ b/login/apps/login-test-integration/integration/login.cy.ts @@ -0,0 +1,172 @@ +import { stub } from "../support/e2e"; + +describe("login", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowUsernamePassword: true, + }, + }, + }); + }); + describe("password login", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "ListUsers", { + data: { + details: { + totalResult: 1, + }, + result: [ + { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: true, + }, + }, + }, + ], + }, + }); + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [1], // 1 for password authentication + }, + }); + }); + it("should redirect a user with password authentication to /password", () => { + cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/password"); + }); + describe("with passkey prompt", () => { + beforeEach(() => { + stub("zitadel.session.v2.SessionService", "SetSession", { + data: { + details: { + sequence: 859, + changeDate: "2023-07-04T07:58:20.126Z", + resourceOwner: "220516472055706145", + }, + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + }); + // it("should prompt a user to setup passwordless authentication if passkey is allowed in the login settings", () => { + // cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + // cy.location("pathname", { timeout: 10_000 }).should("eq", "/password"); + // cy.get('input[type="password"]').focus().type("MyStrongPassword!1"); + // cy.get('button[type="submit"]').click(); + // cy.location("pathname", { timeout: 10_000 }).should( + // "eq", + // "/passkey/set", + // ); + // }); + }); + }); + describe("passkey login", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "ListUsers", { + data: { + details: { + totalResult: 1, + }, + result: [ + { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: true, + }, + }, + }, + ], + }, + }); + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [2], // 2 for passwordless authentication + }, + }); + }); + + it("should redirect a user with passwordless authentication to /passkey", () => { + cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey"); + }); + }); +}); diff --git a/login/apps/login-test-integration/integration/register-idp.cy.ts b/login/apps/login-test-integration/integration/register-idp.cy.ts new file mode 100644 index 0000000000..73a0c32e00 --- /dev/null +++ b/login/apps/login-test-integration/integration/register-idp.cy.ts @@ -0,0 +1,21 @@ +import { stub } from "../support/e2e"; + +const IDP_URL = "https://example.com/idp/url"; + +describe("register idps", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "StartIdentityProviderIntent", { + data: { + authUrl: IDP_URL, + }, + }); + }); + + it("should redirect the user to the correct url", () => { + cy.visit("/idp"); + cy.get('button[e2e="google"]').click(); + cy.origin(IDP_URL, { args: IDP_URL }, (url) => { + cy.location("href", { timeout: 10_000 }).should("eq", url); + }); + }); +}); diff --git a/login/apps/login-test-integration/integration/register.cy.ts b/login/apps/login-test-integration/integration/register.cy.ts new file mode 100644 index 0000000000..44c53647c1 --- /dev/null +++ b/login/apps/login-test-integration/integration/register.cy.ts @@ -0,0 +1,73 @@ +import { stub } from "../support/e2e"; + +describe("register", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowRegister: true, + allowUsernamePassword: true, + defaultRedirectUri: "", + }, + }, + }); + stub("zitadel.user.v2.UserService", "AddHumanUser", { + data: { + userId: "221394658884845598", + }, + }); + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + }); + + it("should redirect a user who selects passwordless on register to /passkey/set", () => { + cy.visit("/register"); + cy.get('input[data-testid="firstname-text-input"]').focus().type("John"); + cy.get('input[data-testid="lastname-text-input"]').focus().type("Doe"); + cy.get('input[data-testid="email-text-input"]').focus().type("john@zitadel.com"); + cy.get('input[type="checkbox"][value="privacypolicy"]').check(); + cy.get('input[type="checkbox"][value="tos"]').check(); + cy.get('button[type="submit"]').click(); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey/set"); + }); +}); diff --git a/login/apps/login-test-integration/integration/verify.cy.ts b/login/apps/login-test-integration/integration/verify.cy.ts new file mode 100644 index 0000000000..db80cea720 --- /dev/null +++ b/login/apps/login-test-integration/integration/verify.cy.ts @@ -0,0 +1,95 @@ +import { stub } from "../support/e2e"; + +describe("verify email", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [1], // set one method such that we know that the user was not invited + }, + }); + + stub("zitadel.user.v2.UserService", "SendEmailCode"); + + stub("zitadel.user.v2.UserService", "GetUserByID", { + data: { + user: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: false, // email is not verified yet + }, + }, + }, + }, + }); + + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + }); + + it("shows an error if email code validation failed", () => { + stub("zitadel.user.v2.UserService", "VerifyEmail", { + code: 3, + error: "error validating code", + }); + // TODO: Avoid uncaught exception in application + cy.once("uncaught:exception", () => false); + cy.visit("/verify?userId=221394658884845598&code=abc"); + cy.contains("Could not verify email", { timeout: 10_000 }); + }); +}); diff --git a/login/apps/login-test-integration/package.json b/login/apps/login-test-integration/package.json new file mode 100644 index 0000000000..f45c5a3413 --- /dev/null +++ b/login/apps/login-test-integration/package.json @@ -0,0 +1,17 @@ +{ + "name": "login-test-integration", + "private": true, + "scripts": { + "test:integration": "dotenv -e ../login/.env.test pnpm exec cypress", + "test:integration:setup": "cd ../.. && make login_test_integration_dev" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "concurrently": "^9.1.2", + "cypress": "^14.3.2", + "env-cmd": "^10.0.0", + "nodemon": "^3.1.9", + "start-server-and-test": "^2.0.11", + "typescript": "^5.8.3" + } +} diff --git a/login/apps/login-test-integration/support/e2e.ts b/login/apps/login-test-integration/support/e2e.ts new file mode 100644 index 0000000000..58056c973e --- /dev/null +++ b/login/apps/login-test-integration/support/e2e.ts @@ -0,0 +1,29 @@ +const url = Cypress.env("CORE_MOCK_STUBS_URL") || "http://localhost:22220/v1/stubs"; + +function removeStub(service: string, method: string) { + return cy.request({ + url, + method: "DELETE", + qs: { + service, + method, + }, + }); +} + +export function stub(service: string, method: string, out?: any) { + removeStub(service, method); + return cy.request({ + url, + method: "POST", + body: { + stubs: [ + { + service, + method, + out, + }, + ], + }, + }); +} diff --git a/login/apps/login-test-integration/tsconfig.json b/login/apps/login-test-integration/tsconfig.json new file mode 100644 index 0000000000..18edb199ac --- /dev/null +++ b/login/apps/login-test-integration/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"] + }, + "include": ["**/*.ts"] +} diff --git a/login/apps/login-test-integration/turbo.json b/login/apps/login-test-integration/turbo.json new file mode 100644 index 0000000000..2e2c7cfb42 --- /dev/null +++ b/login/apps/login-test-integration/turbo.json @@ -0,0 +1,10 @@ +{ + "extends": ["//"], + "tasks": { + "test:integration:setup": { + "interactive": true, + "cache": false, + "persistent": true + } + } +} diff --git a/login/apps/login/.env.test b/login/apps/login/.env.test new file mode 100644 index 0000000000..ee70003348 --- /dev/null +++ b/login/apps/login/.env.test @@ -0,0 +1,5 @@ +NEXT_PUBLIC_BASE_PATH="" +ZITADEL_API_URL=http://localhost:22222 +ZITADEL_SERVICE_USER_TOKEN="yolo" +EMAIL_VERIFICATION=true +DEBUG=true diff --git a/login/apps/login/.eslintrc.cjs b/login/apps/login/.eslintrc.cjs new file mode 100755 index 0000000000..f5383dd47a --- /dev/null +++ b/login/apps/login/.eslintrc.cjs @@ -0,0 +1,12 @@ +module.exports = { + extends: ["next/core-web-vitals"], + ignorePatterns: ["external/**/*.ts"], + rules: { + "@next/next/no-html-link-for-pages": "off", + }, + settings: { + react: { + version: "detect", + }, + }, +}; diff --git a/login/apps/login/.gitignore b/login/apps/login/.gitignore new file mode 100644 index 0000000000..caf3c1ec81 --- /dev/null +++ b/login/apps/login/.gitignore @@ -0,0 +1,3 @@ +custom-config.js +.env*.local +standalone diff --git a/login/apps/login/.prettierignore b/login/apps/login/.prettierignore new file mode 100644 index 0000000000..dbcbbd11d1 --- /dev/null +++ b/login/apps/login/.prettierignore @@ -0,0 +1,2 @@ +.next +/external \ No newline at end of file diff --git a/login/apps/login/constants/csp.js b/login/apps/login/constants/csp.js new file mode 100644 index 0000000000..5cc1e254f3 --- /dev/null +++ b/login/apps/login/constants/csp.js @@ -0,0 +1,2 @@ +export const DEFAULT_CSP = + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com; connect-src 'self'; child-src; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; img-src 'self' https://vercel.com;"; diff --git a/login/apps/login/locales/de.json b/login/apps/login/locales/de.json new file mode 100644 index 0000000000..75897a628e --- /dev/null +++ b/login/apps/login/locales/de.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Zurück" + }, + "accounts": { + "title": "Konten", + "description": "Wählen Sie das Konto aus, das Sie verwenden möchten.", + "addAnother": "Ein weiteres Konto hinzufügen", + "noResults": "Keine Konten gefunden" + }, + "logout": { + "title": "Logout", + "description": "Wählen Sie den Account aus, das Sie entfernen möchten", + "noResults": "Keine Konten gefunden", + "clear": "Session beenden", + "verifiedAt": "Zuletzt aktiv: {time}", + "success": { + "title": "Logout erfolgreich", + "description": "Sie haben sich erfolgreich abgemeldet." + } + }, + "loginname": { + "title": "Willkommen zurück!", + "description": "Geben Sie Ihre Anmeldedaten ein.", + "register": "Neuen Benutzer registrieren", + "submit": "Weiter" + }, + "password": { + "verify": { + "title": "Passwort", + "description": "Geben Sie Ihr Passwort ein.", + "resetPassword": "Passwort zurücksetzen", + "submit": "Weiter" + }, + "set": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "codeSent": "Ein Code wurde an Ihre E-Mail-Adresse gesendet.", + "noCodeReceived": "Keinen Code erhalten?", + "resend": "Erneut senden", + "submit": "Weiter" + }, + "change": { + "title": "Passwort ändern", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "idp": { + "title": "Mit SSO anmelden", + "description": "Wählen Sie einen der folgenden Anbieter, um sich anzumelden", + "orSignInWith": "oder melden Sie sich an mit", + "signInWithApple": "Mit Apple anmelden", + "signInWithGoogle": "Mit Google anmelden", + "signInWithAzureAD": "Mit AzureAD anmelden", + "signInWithGithub": "Mit GitHub anmelden", + "signInWithGitlab": "Mit GitLab anmelden", + "loginSuccess": { + "title": "Anmeldung erfolgreich", + "description": "Sie haben sich erfolgreich angemeldet!" + }, + "linkingSuccess": { + "title": "Konto verknüpft", + "description": "Sie haben Ihr Konto erfolgreich verknüpft!" + }, + "registerSuccess": { + "title": "Registrierung erfolgreich", + "description": "Sie haben sich erfolgreich registriert!" + }, + "loginError": { + "title": "Anmeldung fehlgeschlagen", + "description": "Beim Anmelden ist ein Fehler aufgetreten." + }, + "linkingError": { + "title": "Konto-Verknüpfung fehlgeschlagen", + "description": "Beim Verknüpfen Ihres Kontos ist ein Fehler aufgetreten." + }, + "completeRegister": { + "title": "Registrierung abschließen", + "description": "Bitte vervollständige die Registrierung, um dein Konto zu erstellen." + } + }, + "ldap": { + "title": "LDAP Login", + "description": "Geben Sie Ihre LDAP-Anmeldedaten ein.", + "username": "Benutzername", + "password": "Passwort", + "submit": "Weiter" + }, + "mfa": { + "verify": { + "title": "Bestätigen Sie Ihre Identität", + "description": "Wählen Sie einen der folgenden Faktoren.", + "noResults": "Keine zweiten Faktoren verfügbar, um sie einzurichten." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Wählen Sie einen der folgenden zweiten Faktoren.", + "skip": "Überspringen" + } + }, + "otp": { + "verify": { + "title": "2-Faktor bestätigen", + "totpDescription": "Geben Sie den Code aus Ihrer Authentifizierungs-App ein.", + "smsDescription": "Geben Sie den Code ein, den Sie per SMS erhalten haben.", + "emailDescription": "Geben Sie den Code ein, den Sie per E-Mail erhalten haben.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "submit": "Weiter" + }, + "set": { + "title": "2-Faktor einrichten", + "totpDescription": "Scannen Sie den QR-Code mit Ihrer Authentifizierungs-App.", + "smsDescription": "Geben Sie Ihre Telefonnummer ein, um einen Code per SMS zu erhalten.", + "emailDescription": "Geben Sie Ihre E-Mail-Adresse ein, um einen Code per E-Mail zu erhalten.", + "totpRegisterDescription": "Scannen Sie den QR-Code oder navigieren Sie manuell zur URL.", + "submit": "Weiter" + } + }, + "passkey": { + "verify": { + "title": "Mit einem Passkey authentifizieren", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "usePassword": "Passwort verwenden", + "submit": "Weiter" + }, + "set": { + "title": "Passkey einrichten", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "info": { + "description": "Ein Passkey ist eine Authentifizierungsmethode auf einem Gerät wie Ihr Fingerabdruck, Apple FaceID oder ähnliches.", + "link": "Passwortlose Authentifizierung" + }, + "skip": "Überspringen", + "submit": "Weiter" + } + }, + "u2f": { + "verify": { + "title": "2-Faktor bestätigen", + "description": "Bestätigen Sie Ihr Konto mit Ihrem Gerät." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Richten Sie ein Gerät als zweiten Faktor ein.", + "submit": "Weiter" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registrierung deaktiviert", + "description": "Die Registrierung ist deaktiviert. Bitte wenden Sie sich an den Administrator." + }, + "missingdata": { + "title": "Registrierung fehlgeschlagen", + "description": "Einige Daten fehlen. Bitte überprüfen Sie Ihre Eingaben." + }, + "title": "Registrieren", + "description": "Erstellen Sie Ihr ZITADEL-Konto.", + "noMethodAvailableWarning": "Keine Authentifizierungsmethode verfügbar. Bitte wenden Sie sich an den Administrator.", + "selectMethod": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten", + "agreeTo": "Um sich zu registrieren, müssen Sie den Nutzungsbedingungen zustimmen", + "termsOfService": "Nutzungsbedingungen", + "privacyPolicy": "Datenschutzrichtlinie", + "submit": "Weiter", + "orUseIDP": "oder verwenden Sie einen Identitätsanbieter", + "password": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "invite": { + "title": "Benutzer einladen", + "description": "Geben Sie die E-Mail-Adresse des Benutzers ein, den Sie einladen möchten.", + "info": "Der Benutzer erhält eine E-Mail mit einem Link, um sich zu registrieren.", + "notAllowed": "Sie haben keine Berechtigung, Benutzer einzuladen.", + "submit": "Einladen", + "success": { + "title": "Einladung erfolgreich", + "description": "Der Benutzer wurde erfolgreich eingeladen.", + "verified": "Der Benutzer wurde eingeladen und hat seine E-Mail bereits verifiziert.", + "notVerifiedYet": "Der Benutzer wurde eingeladen. Er erhält eine E-Mail mit weiteren Anweisungen.", + "submit": "Weiteren Benutzer einladen" + } + }, + "signedin": { + "title": "Willkommen {user}!", + "description": "Sie sind angemeldet.", + "continue": "Weiter", + "error": { + "title": "Fehler", + "description": "Ein Fehler ist aufgetreten." + } + }, + "verify": { + "userIdMissing": "Keine Benutzer-ID angegeben!", + "successTitle": "Benutzer verifiziert", + "successDescription": "Der Benutzer wurde erfolgreich verifiziert.", + "setupAuthenticator": "Authentifikator einrichten", + "verify": { + "title": "Benutzer verifizieren", + "description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "codeSent": "Ein Code wurde gerade an Ihre E-Mail-Adresse gesendet.", + "submit": "Weiter" + } + }, + "authenticator": { + "title": "Authentifizierungsmethode auswählen", + "description": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten.", + "noMethodsAvailable": "Keine Authentifizierungsmethoden verfügbar", + "allSetup": "Sie haben bereits einen Authentifikator eingerichtet!", + "linkWithIDP": "oder verknüpfe mit einem Identitätsanbieter" + }, + "device": { + "usercode": { + "title": "Gerätecode", + "description": "Geben Sie den Code ein.", + "submit": "Weiter" + }, + "request": { + "title": "{appName} möchte eine Verbindung herstellen:", + "disclaimer": "{appName} hat Zugriff auf:", + "description": "Durch Klicken auf Zulassen erlauben Sie {appName} und Zitadel, Ihre Informationen gemäß ihren jeweiligen Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können diesen Zugriff jederzeit widerrufen.", + "submit": "Zulassen", + "deny": "Ablehnen" + }, + "scope": { + "openid": "Überprüfen Ihrer Identität.", + "email": "Zugriff auf Ihre E-Mail-Adresse.", + "profile": "Zugriff auf Ihre vollständigen Profilinformationen.", + "offline_access": "Erlauben Sie den Offline-Zugriff auf Ihr Konto." + } + }, + "error": { + "noUserCode": "Kein Benutzercode angegeben!", + "noDeviceRequest": " Es wurde keine Geräteanforderung gefunden. Bitte überprüfen Sie die URL.", + "unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.", + "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + "failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "tryagain": "Erneut versuchen" + } +} diff --git a/login/apps/login/locales/en.json b/login/apps/login/locales/en.json new file mode 100644 index 0000000000..9f95403063 --- /dev/null +++ b/login/apps/login/locales/en.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Back" + }, + "accounts": { + "title": "Accounts", + "description": "Select the account you want to use.", + "addAnother": "Add another account", + "noResults": "No accounts found" + }, + "logout": { + "title": "Logout", + "description": "Click an account to end the session", + "noResults": "No accounts found", + "clear": "End Session", + "verifiedAt": "Last active: {time}", + "success": { + "title": "Logout successful", + "description": "You have successfully logged out." + } + }, + "loginname": { + "title": "Welcome back!", + "description": "Enter your login data.", + "register": "Register new user", + "submit": "Continue" + }, + "password": { + "verify": { + "title": "Password", + "description": "Enter your password.", + "resetPassword": "Reset Password", + "submit": "Continue" + }, + "set": { + "title": "Set Password", + "description": "Set the password for your account", + "codeSent": "A code has been sent to your email address.", + "noCodeReceived": "Didn't receive a code?", + "resend": "Resend code", + "submit": "Continue" + }, + "change": { + "title": "Change Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "idp": { + "title": "Sign in with SSO", + "description": "Select one of the following providers to sign in", + "orSignInWith": "or sign in with", + "signInWithApple": "Sign in with Apple", + "signInWithGoogle": "Sign in with Google", + "signInWithAzureAD": "Sign in with AzureAD", + "signInWithGithub": "Sign in with GitHub", + "signInWithGitlab": "Sign in with GitLab", + "loginSuccess": { + "title": "Login successful", + "description": "You have successfully been loggedIn!" + }, + "linkingSuccess": { + "title": "Account linked", + "description": "You have successfully linked your account!" + }, + "registerSuccess": { + "title": "Registration successful", + "description": "You have successfully registered!" + }, + "loginError": { + "title": "Login failed", + "description": "An error occurred while trying to login." + }, + "linkingError": { + "title": "Account linking failed", + "description": "An error occurred while trying to link your account." + }, + "completeRegister": { + "title": "Complete your data", + "description": "You need to complete your registration by providing your email address and name." + } + }, + "ldap": { + "title": "LDAP Login", + "description": "Enter your LDAP credentials.", + "username": "Username", + "password": "Password", + "submit": "Continue" + }, + "mfa": { + "verify": { + "title": "Verify your identity", + "description": "Choose one of the following factors.", + "noResults": "No second factors available to setup." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Choose one of the following second factors.", + "skip": "Skip" + } + }, + "otp": { + "verify": { + "title": "Verify 2-Factor", + "totpDescription": "Enter the code from your authenticator app.", + "smsDescription": "Enter the code you received via SMS.", + "emailDescription": "Enter the code you received via email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "submit": "Continue" + }, + "set": { + "title": "Set up 2-Factor", + "totpDescription": "Scan the QR code with your authenticator app.", + "smsDescription": "Enter your phone number to receive a code via SMS.", + "emailDescription": "Enter your email address to receive a code via email.", + "totpRegisterDescription": "Scan the QR Code or navigate to the URL manually.", + "submit": "Continue" + } + }, + "passkey": { + "verify": { + "title": "Authenticate with a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "usePassword": "Use password", + "submit": "Continue" + }, + "set": { + "title": "Setup a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "info": { + "description": "A passkey is an authentication method on a device like your fingerprint, Apple FaceID or similar. ", + "link": "Passwordless Authentication" + }, + "skip": "Skip", + "submit": "Continue" + } + }, + "u2f": { + "verify": { + "title": "Verify 2-Factor", + "description": "Verify your account with your device." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Set up a device as a second factor.", + "submit": "Continue" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "The registration is disabled. Please contact your administrator." + }, + "missingdata": { + "title": "Missing data", + "description": "Provide email, first and last name to register." + }, + "title": "Register", + "description": "Create your ZITADEL account.", + "noMethodAvailableWarning": "No authentication method available. Please contact your administrator.", + "selectMethod": "Select the method you would like to authenticate", + "agreeTo": "To register you must agree to the terms and conditions", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "submit": "Continue", + "orUseIDP": "or use an Identity Provider", + "password": { + "title": "Set Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "invite": { + "title": "Invite User", + "description": "Provide the email address and the name of the user you want to invite.", + "info": "The user will receive an email with further instructions.", + "notAllowed": "Your settings do not allow you to invite users.", + "submit": "Continue", + "success": { + "title": "User invited", + "description": "The email has successfully been sent.", + "verified": "The user has been invited and has already verified his email.", + "notVerifiedYet": "The user has been invited. They will receive an email with further instructions.", + "submit": "Invite another user" + } + }, + "signedin": { + "title": "Welcome {user}!", + "description": "You are signed in.", + "continue": "Continue", + "error": { + "title": "Error", + "description": "An error occurred while trying to sign in." + } + }, + "verify": { + "userIdMissing": "No userId provided!", + "successTitle": "User verified", + "successDescription": "The user has been verified successfully.", + "setupAuthenticator": "Setup authenticator", + "verify": { + "title": "Verify user", + "description": "Enter the Code provided in the verification email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "codeSent": "A code has just been sent to your email address.", + "submit": "Continue" + } + }, + "authenticator": { + "title": "Choose authentication method", + "description": "Select the method you would like to authenticate", + "noMethodsAvailable": "No authentication methods available", + "allSetup": "You have already setup an authenticator!", + "linkWithIDP": "or link with an Identity Provider" + }, + "device": { + "usercode": { + "title": "Device code", + "description": "Enter the code displayed on your app or device.", + "submit": "Continue" + }, + "request": { + "title": "{appName} would like to connect", + "description": "{appName} will have access to:", + "disclaimer": "By clicking Allow, you allow {appName} and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", + "submit": "Allow", + "deny": "Deny" + }, + "scope": { + "openid": "Verify your identity.", + "email": "View your email address.", + "profile": "View your full profile information.", + "offline_access": "Allow offline access to your account." + } + }, + "error": { + "noUserCode": "No user code provided!", + "noDeviceRequest": "No device request found.", + "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", + "sessionExpired": "Your current session has expired. Please login again.", + "failedLoading": "Failed to load data. Please try again.", + "tryagain": "Try Again" + } +} diff --git a/login/apps/login/locales/es.json b/login/apps/login/locales/es.json new file mode 100644 index 0000000000..fe88bb94c6 --- /dev/null +++ b/login/apps/login/locales/es.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Atrás" + }, + "accounts": { + "title": "Cuentas", + "description": "Selecciona la cuenta que deseas usar.", + "addAnother": "Agregar otra cuenta", + "noResults": "No se encontraron cuentas" + }, + "logout": { + "title": "Cerrar sesión", + "description": "Selecciona la cuenta que deseas eliminar", + "noResults": "No se encontraron cuentas", + "clear": "Eliminar sesión", + "verifiedAt": "Última actividad: {time}", + "success": { + "title": "Cierre de sesión exitoso", + "description": "Has cerrado sesión correctamente." + } + }, + "loginname": { + "title": "¡Bienvenido de nuevo!", + "description": "Introduce tus datos de acceso.", + "register": "Registrar nuevo usuario", + "submit": "Continuar" + }, + "password": { + "verify": { + "title": "Contraseña", + "description": "Introduce tu contraseña.", + "resetPassword": "Restablecer contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "codeSent": "Se ha enviado un código a su correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resend": "Reenviar código", + "submit": "Continuar" + }, + "change": { + "title": "Cambiar Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "idp": { + "title": "Iniciar sesión con SSO", + "description": "Selecciona uno de los siguientes proveedores para iniciar sesión", + "orSignInWith": "o iniciar sesión con", + "signInWithApple": "Iniciar sesión con Apple", + "signInWithGoogle": "Iniciar sesión con Google", + "signInWithAzureAD": "Iniciar sesión con AzureAD", + "signInWithGithub": "Iniciar sesión con GitHub", + "signInWithGitlab": "Iniciar sesión con GitLab", + "loginSuccess": { + "title": "Inicio de sesión exitoso", + "description": "¡Has iniciado sesión con éxito!" + }, + "linkingSuccess": { + "title": "Cuenta vinculada", + "description": "¡Has vinculado tu cuenta con éxito!" + }, + "registerSuccess": { + "title": "Registro exitoso", + "description": "¡Te has registrado con éxito!" + }, + "loginError": { + "title": "Error de inicio de sesión", + "description": "Ocurrió un error al intentar iniciar sesión." + }, + "linkingError": { + "title": "Error al vincular la cuenta", + "description": "Ocurrió un error al intentar vincular tu cuenta." + }, + "completeRegister": { + "title": "Completar registro", + "description": "Para completar el registro, debes establecer una contraseña." + } + }, + "ldap": { + "title": "Iniciar sesión con LDAP", + "description": "Introduce tus credenciales LDAP.", + "username": "Nombre de usuario", + "password": "Contraseña", + "submit": "Continuar" + }, + "mfa": { + "verify": { + "title": "Verifica tu identidad", + "description": "Elige uno de los siguientes factores.", + "noResults": "No hay factores secundarios disponibles para configurar." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Elige uno de los siguientes factores secundarios.", + "skip": "Omitir" + } + }, + "otp": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "totpDescription": "Introduce el código de tu aplicación de autenticación.", + "smsDescription": "Introduce el código que recibiste por SMS.", + "emailDescription": "Introduce el código que recibiste por correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "submit": "Continuar" + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "totpDescription": "Escanea el código QR con tu aplicación de autenticación.", + "smsDescription": "Introduce tu número de teléfono para recibir un código por SMS.", + "emailDescription": "Introduce tu dirección de correo electrónico para recibir un código por correo electrónico.", + "totpRegisterDescription": "Escanea el código QR o navega manualmente a la URL.", + "submit": "Continuar" + } + }, + "passkey": { + "verify": { + "title": "Autenticar con una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "usePassword": "Usar contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Configurar una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "info": { + "description": "Una clave de acceso es un método de autenticación en un dispositivo como tu huella digital, Apple FaceID o similar.", + "link": "Autenticación sin contraseña" + }, + "skip": "Omitir", + "submit": "Continuar" + } + }, + "u2f": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "description": "Verifica tu cuenta con tu dispositivo." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Configura un dispositivo como segundo factor.", + "submit": "Continuar" + } + }, + "register": { + "methods": { + "passkey": "Clave de acceso", + "password": "Contraseña" + }, + "disabled": { + "title": "Registro deshabilitado", + "description": "Registrarse está deshabilitado en este momento." + }, + "missingdata": { + "title": "Datos faltantes", + "description": "No se proporcionaron datos suficientes para el registro." + }, + "title": "Registrarse", + "description": "Crea tu cuenta ZITADEL.", + "noMethodAvailableWarning": "No hay métodos de autenticación disponibles. Por favor, contacta a tu administrador.", + "selectMethod": "Selecciona el método con el que deseas autenticarte", + "agreeTo": "Para registrarte debes aceptar los términos y condiciones", + "termsOfService": "Términos de Servicio", + "privacyPolicy": "Política de Privacidad", + "submit": "Continuar", + "orUseIDP": "o usa un Proveedor de Identidad", + "password": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "invite": { + "title": "Invitar usuario", + "description": "Introduce el correo electrónico del usuario que deseas invitar.", + "info": "El usuario recibirá un correo electrónico con un enlace para completar el registro.", + "notAllowed": "No tienes permiso para invitar usuarios.", + "submit": "Invitar usuario", + "success": { + "title": "¡Usuario invitado!", + "description": "El usuario ha sido invitado.", + "verified": "El usuario ha sido invitado y ya ha verificado su correo electrónico.", + "notVerifiedYet": "El usuario ha sido invitado. Recibirá un correo electrónico con más instrucciones.", + "submit": "Invitar a otro usuario" + } + }, + "signedin": { + "title": "¡Bienvenido {user}!", + "description": "Has iniciado sesión.", + "continue": "Continuar", + "error": { + "title": "Error", + "description": "Ocurrió un error al iniciar sesión." + } + }, + "verify": { + "userIdMissing": "¡No se proporcionó userId!", + "successTitle": "Usuario verificado", + "successDescription": "El usuario ha sido verificado con éxito.", + "setupAuthenticator": "Configurar autenticador", + "verify": { + "title": "Verificar usuario", + "description": "Introduce el código proporcionado en el correo electrónico de verificación.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "codeSent": "Se ha enviado un código a tu dirección de correo electrónico.", + "submit": "Continuar" + } + }, + "authenticator": { + "title": "Seleccionar método de autenticación", + "description": "Selecciona el método con el que deseas autenticarte", + "noMethodsAvailable": "No hay métodos de autenticación disponibles", + "allSetup": "¡Ya has configurado un autenticador!", + "linkWithIDP": "o vincúlalo con un proveedor de identidad" + }, + "device": { + "usercode": { + "title": "Código del dispositivo", + "description": "Introduce el código.", + "submit": "Continuar" + }, + "request": { + "title": "{appName} desea conectarse:", + "description": "{appName} tendrá acceso a:", + "disclaimer": "Al hacer clic en Permitir, autorizas a {appName} y a Zitadel a usar tu información de acuerdo con sus respectivos términos de servicio y políticas de privacidad. Puedes revocar este acceso en cualquier momento.", + "submit": "Permitir", + "deny": "Denegar" + }, + "scope": { + "openid": "Verifica tu identidad.", + "email": "Accede a tu dirección de correo electrónico.", + "profile": "Accede a la información completa de tu perfil.", + "offline_access": "Permitir acceso sin conexión a tu cuenta." + } + }, + "error": { + "noUserCode": "¡No se proporcionó código de usuario!", + "noDeviceRequest": "No se encontró ninguna solicitud de dispositivo.", + "unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.", + "sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.", + "failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.", + "tryagain": "Intentar de nuevo" + } +} diff --git a/login/apps/login/locales/it.json b/login/apps/login/locales/it.json new file mode 100644 index 0000000000..1229a1a4c0 --- /dev/null +++ b/login/apps/login/locales/it.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Indietro" + }, + "accounts": { + "title": "Account", + "description": "Seleziona l'account che desideri utilizzare.", + "addAnother": "Aggiungi un altro account", + "noResults": "Nessun account trovato" + }, + "logout": { + "title": "Esci", + "description": "Seleziona l'account che desideri uscire", + "noResults": "Nessun account trovato", + "clear": "Elimina sessione", + "verifiedAt": "Ultima attività: {time}", + "success": { + "title": "Uscita riuscita", + "description": "Hai effettuato l'uscita con successo." + } + }, + "loginname": { + "title": "Bentornato!", + "description": "Inserisci i tuoi dati di accesso.", + "register": "Registrati come nuovo utente", + "submit": "Continua" + }, + "password": { + "verify": { + "title": "Password", + "description": "Inserisci la tua password.", + "resetPassword": "Reimposta Password", + "submit": "Continua" + }, + "set": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "codeSent": "Un codice è stato inviato al tuo indirizzo email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resend": "Invia di nuovo", + "submit": "Continua" + }, + "change": { + "title": "Cambia Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "idp": { + "title": "Accedi con SSO", + "description": "Seleziona uno dei seguenti provider per accedere", + "orSignInWith": "o accedi con", + "signInWithApple": "Accedi con Apple", + "signInWithGoogle": "Accedi con Google", + "signInWithAzureAD": "Accedi con AzureAD", + "signInWithGithub": "Accedi con GitHub", + "signInWithGitlab": "Accedi con GitLab", + "loginSuccess": { + "title": "Accesso riuscito", + "description": "Accesso effettuato con successo!" + }, + "linkingSuccess": { + "title": "Account collegato", + "description": "Hai collegato con successo il tuo account!" + }, + "registerSuccess": { + "title": "Registrazione riuscita", + "description": "Registrazione effettuata con successo!" + }, + "loginError": { + "title": "Accesso fallito", + "description": "Si è verificato un errore durante il tentativo di accesso." + }, + "linkingError": { + "title": "Collegamento account fallito", + "description": "Si è verificato un errore durante il tentativo di collegare il tuo account." + }, + "completeRegister": { + "title": "Completa la registrazione", + "description": "Completa la registrazione del tuo account." + } + }, + "ldap": { + "title": "Accedi con LDAP", + "description": "Inserisci le tue credenziali LDAP.", + "username": "Nome utente", + "password": "Password", + "submit": "Continua" + }, + "mfa": { + "verify": { + "title": "Verifica la tua identità", + "description": "Scegli uno dei seguenti fattori.", + "noResults": "Nessun secondo fattore disponibile per la configurazione." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Scegli uno dei seguenti secondi fattori.", + "skip": "Salta" + } + }, + "otp": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "totpDescription": "Inserisci il codice dalla tua app di autenticazione.", + "smsDescription": "Inserisci il codice ricevuto via SMS.", + "emailDescription": "Inserisci il codice ricevuto via email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "submit": "Continua" + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "totpDescription": "Scansiona il codice QR con la tua app di autenticazione.", + "smsDescription": "Inserisci il tuo numero di telefono per ricevere un codice via SMS.", + "emailDescription": "Inserisci il tuo indirizzo email per ricevere un codice via email.", + "totpRegisterDescription": "Scansiona il codice QR o naviga manualmente all'URL.", + "submit": "Continua" + } + }, + "passkey": { + "verify": { + "title": "Autenticati con una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "usePassword": "Usa password", + "submit": "Continua" + }, + "set": { + "title": "Configura una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "info": { + "description": "Una passkey è un metodo di autenticazione su un dispositivo come la tua impronta digitale, Apple FaceID o simili.", + "link": "Autenticazione senza password" + }, + "skip": "Salta", + "submit": "Continua" + } + }, + "u2f": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "description": "Verifica il tuo account con il tuo dispositivo." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Configura un dispositivo come secondo fattore.", + "submit": "Continua" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "Registrazione disabilitata. Contatta l'amministratore di sistema per assistenza." + }, + "missingdata": { + "title": "Registrazione", + "description": "Inserisci i tuoi dati per registrarti." + }, + "title": "Registrati", + "description": "Crea il tuo account ZITADEL.", + "noMethodAvailableWarning": "Nessun metodo di autenticazione disponibile. Contatta l'amministratore di sistema per assistenza.", + "selectMethod": "Seleziona il metodo con cui desideri autenticarti", + "agreeTo": "Per registrarti devi accettare i termini e le condizioni", + "termsOfService": "Termini di Servizio", + "privacyPolicy": "Informativa sulla Privacy", + "submit": "Continua", + "orUseIDP": "o usa un Identity Provider", + "password": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "invite": { + "title": "Invita Utente", + "description": "Inserisci l'indirizzo email dell'utente che desideri invitare.", + "info": "L'utente riceverà un'email con ulteriori istruzioni.", + "notAllowed": "Non hai i permessi per invitare un utente.", + "submit": "Invita Utente", + "success": { + "title": "Invito inviato", + "description": "L'utente è stato invitato con successo.", + "verified": "L'utente è stato invitato e ha già verificato la sua email.", + "notVerifiedYet": "L'utente è stato invitato. Riceverà un'email con ulteriori istruzioni.", + "submit": "Invita un altro utente" + } + }, + "signedin": { + "title": "Benvenuto {user}!", + "description": "Sei connesso.", + "continue": "Continua", + "error": { + "title": "Errore", + "description": "Si è verificato un errore durante il tentativo di accesso." + } + }, + "verify": { + "userIdMissing": "Nessun userId fornito!", + "successTitle": "Utente verificato", + "successDescription": "L'utente è stato verificato con successo.", + "setupAuthenticator": "Configura autenticatore", + "verify": { + "title": "Verifica utente", + "description": "Inserisci il codice fornito nell'email di verifica.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "codeSent": "Un codice è stato appena inviato al tuo indirizzo email.", + "submit": "Continua" + } + }, + "authenticator": { + "title": "Seleziona metodo di autenticazione", + "description": "Seleziona il metodo con cui desideri autenticarti", + "noMethodsAvailable": "Nessun metodo di autenticazione disponibile", + "allSetup": "Hai già configurato un autenticatore!", + "linkWithIDP": "o collega con un Identity Provider" + }, + "device": { + "usercode": { + "title": "Codice dispositivo", + "description": "Inserisci il codice.", + "submit": "Continua" + }, + "request": { + "title": "{appName} desidera connettersi:", + "description": "{appName} avrà accesso a:", + "disclaimer": "Cliccando su Consenti, autorizzi {appName} e Zitadel a utilizzare le tue informazioni in conformità con i rispettivi termini di servizio e politiche sulla privacy. Puoi revocare questo accesso in qualsiasi momento.", + "submit": "Consenti", + "deny": "Nega" + }, + "scope": { + "openid": "Verifica la tua identità.", + "email": "Accedi al tuo indirizzo email.", + "profile": "Accedi alle informazioni complete del tuo profilo.", + "offline_access": "Consenti l'accesso offline al tuo account." + } + }, + "error": { + "noUserCode": "Nessun codice utente fornito!", + "noDeviceRequest": "Nessuna richiesta di dispositivo trovata.", + "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", + "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", + "failedLoading": "Impossibile caricare i dati. Riprova.", + "tryagain": "Riprova" + } +} diff --git a/login/apps/login/locales/pl.json b/login/apps/login/locales/pl.json new file mode 100644 index 0000000000..9fea6a19fa --- /dev/null +++ b/login/apps/login/locales/pl.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Powrót" + }, + "accounts": { + "title": "Konta", + "description": "Wybierz konto, którego chcesz użyć.", + "addAnother": "Dodaj kolejne konto", + "noResults": "Nie znaleziono kont" + }, + "logout": { + "title": "Wyloguj się", + "description": "Wybierz konto, które chcesz usunąć", + "noResults": "Nie znaleziono kont", + "clear": "Usuń sesję", + "verifiedAt": "Ostatnia aktywność: {time}", + "success": { + "title": "Wylogowanie udane", + "description": "Pomyślnie się wylogowałeś." + } + }, + "loginname": { + "title": "Witamy ponownie!", + "description": "Wprowadź dane logowania.", + "register": "Zarejestruj nowego użytkownika", + "submit": "Kontynuuj" + }, + "password": { + "verify": { + "title": "Hasło", + "description": "Wprowadź swoje hasło.", + "resetPassword": "Zresetuj hasło", + "submit": "Kontynuuj" + }, + "set": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "codeSent": "Kod został wysłany na twój adres e-mail.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resend": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "change": { + "title": "Zmień hasło", + "description": "Ustaw nowe hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "idp": { + "title": "Zaloguj się za pomocą SSO", + "description": "Wybierz jednego z poniższych dostawców, aby się zalogować", + "orSignInWith": "lub zaloguj się przez", + "signInWithApple": "Zaloguj się przez Apple", + "signInWithGoogle": "Zaloguj się przez Google", + "signInWithAzureAD": "Zaloguj się przez AzureAD", + "signInWithGithub": "Zaloguj się przez GitHub", + "signInWithGitlab": "Zaloguj się przez GitLab", + "loginSuccess": { + "title": "Logowanie udane", + "description": "Zostałeś pomyślnie zalogowany!" + }, + "linkingSuccess": { + "title": "Konto powiązane", + "description": "Pomyślnie powiązałeś swoje konto!" + }, + "registerSuccess": { + "title": "Rejestracja udana", + "description": "Pomyślnie się zarejestrowałeś!" + }, + "loginError": { + "title": "Logowanie nieudane", + "description": "Wystąpił błąd podczas próby logowania." + }, + "linkingError": { + "title": "Powiązanie konta nie powiodło się", + "description": "Wystąpił błąd podczas próby powiązania konta." + }, + "completeRegister": { + "title": "Ukończ rejestrację", + "description": "Ukończ rejestrację swojego konta." + } + }, + "ldap": { + "title": "Zaloguj się przez LDAP", + "description": "Wprowadź swoje dane logowania LDAP.", + "username": "Nazwa użytkownika", + "password": "Hasło", + "submit": "Kontynuuj" + }, + "mfa": { + "verify": { + "title": "Zweryfikuj swoją tożsamość", + "description": "Wybierz jeden z poniższych sposobów weryfikacji.", + "noResults": "Nie znaleziono dostępnych metod uwierzytelniania dwuskładnikowego." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Wybierz jedną z poniższych metod drugiego czynnika.", + "skip": "Pomiń" + } + }, + "otp": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Wprowadź kod z aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź kod otrzymany SMS-em.", + "emailDescription": "Wprowadź kod otrzymany e-mailem.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Zeskanuj kod QR za pomocą aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź swój numer telefonu, aby otrzymać kod SMS-em.", + "emailDescription": "Wprowadź swój adres e-mail, aby otrzymać kod e-mailem.", + "totpRegisterDescription": "Zeskanuj kod QR lub otwórz adres URL ręcznie.", + "submit": "Kontynuuj" + } + }, + "passkey": { + "verify": { + "title": "Uwierzytelnij się za pomocą klucza dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "usePassword": "Użyj hasła", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj klucz dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "info": { + "description": "Klucz dostępu to metoda uwierzytelniania na urządzeniu, wykorzystująca np. odcisk palca, Apple FaceID lub podobne rozwiązania.", + "link": "Uwierzytelnianie bez hasła" + }, + "skip": "Pomiń", + "submit": "Kontynuuj" + } + }, + "u2f": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "description": "Zweryfikuj swoje konto za pomocą urządzenia." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Skonfiguruj urządzenie jako dodatkowy czynnik uwierzytelniania.", + "submit": "Kontynuuj" + } + }, + "register": { + "methods": { + "passkey": "Klucz dostępu", + "password": "Hasło" + }, + "disabled": { + "title": "Rejestracja wyłączona", + "description": "Rejestracja jest wyłączona. Skontaktuj się z administratorem." + }, + "missingdata": { + "title": "Brak danych", + "description": "Podaj e-mail, imię i nazwisko, aby się zarejestrować." + }, + "title": "Rejestracja", + "description": "Utwórz konto ZITADEL.", + "noMethodAvailableWarning": "Brak dostępnych metod uwierzytelniania. Skontaktuj się z administratorem.", + "selectMethod": "Wybierz metodę uwierzytelniania, której chcesz użyć", + "agreeTo": "Aby się zarejestrować, musisz zaakceptować warunki korzystania", + "termsOfService": "Regulamin", + "privacyPolicy": "Polityka prywatności", + "submit": "Kontynuuj", + "orUseIDP": "lub użyj dostawcy tożsamości", + "password": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "invite": { + "title": "Zaproś użytkownika", + "description": "Podaj adres e-mail oraz imię i nazwisko użytkownika, którego chcesz zaprosić.", + "info": "Użytkownik otrzyma e-mail z dalszymi instrukcjami.", + "notAllowed": "Twoje ustawienia nie pozwalają na zapraszanie użytkowników.", + "submit": "Kontynuuj", + "success": { + "title": "Użytkownik zaproszony", + "description": "E-mail został pomyślnie wysłany.", + "verified": "Użytkownik został zaproszony i już zweryfikował swój e-mail.", + "notVerifiedYet": "Użytkownik został zaproszony. Otrzyma e-mail z dalszymi instrukcjami.", + "submit": "Zaproś kolejnego użytkownika" + } + }, + "signedin": { + "title": "Witaj {user}!", + "description": "Jesteś zalogowany.", + "continue": "Kontynuuj", + "error": { + "title": "Błąd", + "description": "Nie można załadować danych. Sprawdź połączenie z internetem lub spróbuj ponownie później." + } + }, + "verify": { + "userIdMissing": "Nie podano identyfikatora użytkownika!", + "successTitle": "Weryfikacja zakończona", + "successDescription": "Użytkownik został pomyślnie zweryfikowany.", + "setupAuthenticator": "Skonfiguruj uwierzytelnianie", + "verify": { + "title": "Zweryfikuj użytkownika", + "description": "Wprowadź kod z wiadomości weryfikacyjnej.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "codeSent": "Kod został właśnie wysłany na twój adres e-mail.", + "submit": "Kontynuuj" + } + }, + "authenticator": { + "title": "Wybierz metodę uwierzytelniania", + "description": "Wybierz metodę, której chcesz użyć do uwierzytelnienia.", + "noMethodsAvailable": "Brak dostępnych metod uwierzytelniania", + "allSetup": "Już skonfigurowałeś metodę uwierzytelniania!", + "linkWithIDP": "lub połącz z dostawcą tożsamości" + }, + "device": { + "usercode": { + "title": "Kod urządzenia", + "description": "Wprowadź kod.", + "submit": "Kontynuuj" + }, + "request": { + "title": "{appName} chce się połączyć:", + "description": "{appName} będzie miało dostęp do:", + "disclaimer": "Klikając Zezwól, pozwalasz tej aplikacji i Zitadel na korzystanie z Twoich informacji zgodnie z ich odpowiednimi warunkami użytkowania i politykami prywatności. Możesz cofnąć ten dostęp w dowolnym momencie.", + "submit": "Zezwól", + "deny": "Odmów" + }, + "scope": { + "openid": "Zweryfikuj swoją tożsamość.", + "email": "Uzyskaj dostęp do swojego adresu e-mail.", + "profile": "Uzyskaj dostęp do pełnych informacji o swoim profilu.", + "offline_access": "Zezwól na dostęp offline do swojego konta." + } + }, + "error": { + "noUserCode": "Nie podano kodu użytkownika!", + "noDeviceRequest": "Nie znaleziono żądania urządzenia.", + "unknownContext": "Nie udało się pobrać kontekstu użytkownika. Upewnij się, że najpierw wprowadziłeś nazwę użytkownika lub podałeś login jako parametr wyszukiwania.", + "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", + "failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.", + "tryagain": "Spróbuj ponownie" + } +} diff --git a/login/apps/login/locales/ru.json b/login/apps/login/locales/ru.json new file mode 100644 index 0000000000..e745f1ae59 --- /dev/null +++ b/login/apps/login/locales/ru.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Назад" + }, + "accounts": { + "title": "Аккаунты", + "description": "Выберите аккаунт, который хотите использовать.", + "addAnother": "Добавить другой аккаунт", + "noResults": "Аккаунты не найдены" + }, + "logout": { + "title": "Выход", + "description": "Выберите аккаунт, который хотите удалить", + "noResults": "Аккаунты не найдены", + "clear": "Удалить сессию", + "verifiedAt": "Последняя активность: {time}", + "success": { + "title": "Выход выполнен успешно", + "description": "Вы успешно вышли из системы." + } + }, + "loginname": { + "title": "С возвращением!", + "description": "Введите свои данные для входа.", + "register": "Зарегистрировать нового пользователя", + "submit": "Продолжить" + }, + "password": { + "verify": { + "title": "Пароль", + "description": "Введите ваш пароль.", + "resetPassword": "Сбросить пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "codeSent": "Код отправлен на ваш адрес электронной почты.", + "noCodeReceived": "Не получили код?", + "resend": "Отправить код повторно", + "submit": "Продолжить" + }, + "change": { + "title": "Изменить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "idp": { + "title": "Войти через SSO", + "description": "Выберите одного из провайдеров для входа", + "orSignInWith": "или войти через", + "signInWithApple": "Войти через Apple", + "signInWithGoogle": "Войти через Google", + "signInWithAzureAD": "Войти через AzureAD", + "signInWithGithub": "Войти через GitHub", + "signInWithGitlab": "Войти через GitLab", + "loginSuccess": { + "title": "Вход выполнен успешно", + "description": "Вы успешно вошли в систему!" + }, + "linkingSuccess": { + "title": "Аккаунт привязан", + "description": "Аккаунт успешно привязан!" + }, + "registerSuccess": { + "title": "Регистрация завершена", + "description": "Вы успешно зарегистрировались!" + }, + "loginError": { + "title": "Ошибка входа", + "description": "Произошла ошибка при попытке входа." + }, + "linkingError": { + "title": "Ошибка привязки аккаунта", + "description": "Произошла ошибка при попытке привязать аккаунт." + }, + "completeRegister": { + "title": "Завершите регистрацию", + "description": "Завершите регистрацию вашего аккаунта." + } + }, + "ldap": { + "title": "Войти через LDAP", + "description": "Введите ваши учетные данные LDAP.", + "username": "Имя пользователя", + "password": "Пароль", + "submit": "Продолжить" + }, + "mfa": { + "verify": { + "title": "Подтвердите вашу личность", + "description": "Выберите один из следующих факторов.", + "noResults": "Нет доступных методов двухфакторной аутентификации" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Выберите один из следующих методов.", + "skip": "Пропустить" + } + }, + "otp": { + "verify": { + "title": "Подтверждение 2FA", + "totpDescription": "Введите код из приложения-аутентификатора.", + "smsDescription": "Введите код, полученный по SMS.", + "emailDescription": "Введите код, полученный по email.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "totpDescription": "Отсканируйте QR-код в приложении-аутентификаторе.", + "smsDescription": "Введите номер телефона для получения кода по SMS.", + "emailDescription": "Введите email для получения кода.", + "totpRegisterDescription": "Отсканируйте QR-код или перейдите по ссылке вручную.", + "submit": "Продолжить" + } + }, + "passkey": { + "verify": { + "title": "Аутентификация с помощью пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "usePassword": "Использовать пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "info": { + "description": "Пасскей — метод аутентификации через устройство (отпечаток пальца, Apple FaceID и аналоги).", + "link": "Аутентификация без пароля" + }, + "skip": "Пропустить", + "submit": "Продолжить" + } + }, + "u2f": { + "verify": { + "title": "Подтверждение 2FA", + "description": "Подтвердите аккаунт с помощью устройства." + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Настройте устройство как второй фактор.", + "submit": "Продолжить" + } + }, + "register": { + "methods": { + "passkey": "Пасскей", + "password": "Пароль" + }, + "disabled": { + "title": "Регистрация отключена", + "description": "Регистрация недоступна. Обратитесь к администратору." + }, + "missingdata": { + "title": "Недостаточно данных", + "description": "Укажите email, имя и фамилию для регистрации." + }, + "title": "Регистрация", + "description": "Создайте свой аккаунт ZITADEL.", + "noMethodAvailableWarning": "Нет доступных методов аутентификации. Обратитесь к администратору.", + "selectMethod": "Выберите метод аутентификации", + "agreeTo": "Для регистрации необходимо принять условия:", + "termsOfService": "Условия использования", + "privacyPolicy": "Политика конфиденциальности", + "submit": "Продолжить", + "orUseIDP": "или используйте Identity Provider", + "password": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "invite": { + "title": "Пригласить пользователя", + "description": "Укажите email и имя пользователя для приглашения.", + "info": "Пользователь получит email с инструкциями.", + "notAllowed": "Ваши настройки не позволяют приглашать пользователей.", + "submit": "Продолжить", + "success": { + "title": "Пользователь приглашён", + "description": "Письмо успешно отправлено.", + "verified": "Пользователь приглашён и уже подтвердил email.", + "notVerifiedYet": "Пользователь приглашён. Он получит email с инструкциями.", + "submit": "Пригласить другого пользователя" + } + }, + "signedin": { + "title": "Добро пожаловать, {user}!", + "description": "Вы вошли в систему.", + "continue": "Продолжить", + "error": { + "title": "Ошибка", + "description": "Не удалось войти в систему. Проверьте свои данные и попробуйте снова." + } + }, + "verify": { + "userIdMissing": "Не указан userId!", + "successTitle": "Пользователь подтверждён", + "successDescription": "Пользователь успешно подтверждён.", + "setupAuthenticator": "Настроить аутентификатор", + "verify": { + "title": "Подтверждение пользователя", + "description": "Введите код из письма подтверждения.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "codeSent": "Код отправлен на ваш email.", + "submit": "Продолжить" + } + }, + "authenticator": { + "title": "Выбор метода аутентификации", + "description": "Выберите предпочитаемый метод аутентификации", + "noMethodsAvailable": "Нет доступных методов аутентификации", + "allSetup": "Аутентификатор уже настроен!", + "linkWithIDP": "или привязать через Identity Provider" + }, + "device": { + "usercode": { + "title": "Код устройства", + "description": "Введите код.", + "submit": "Продолжить" + }, + "request": { + "title": "{appName} хочет подключиться:", + "description": "{appName} получит доступ к:", + "disclaimer": "Нажимая «Разрешить», вы разрешаете этому приложению и Zitadel использовать вашу информацию в соответствии с их условиями использования и политиками конфиденциальности. Вы можете отозвать этот доступ в любое время.", + "submit": "Разрешить", + "deny": "Запретить" + }, + "scope": { + "openid": "Проверка вашей личности.", + "email": "Доступ к вашему адресу электронной почты.", + "profile": "Доступ к полной информации вашего профиля.", + "offline_access": "Разрешить офлайн-доступ к вашему аккаунту." + } + }, + "error": { + "noUserCode": "Не указан код пользователя!", + "noDeviceRequest": "Не найдена ни одна заявка на устройство.", + "unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.", + "sessionExpired": "Ваша сессия истекла. Войдите снова.", + "failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.", + "tryagain": "Попробовать снова" + } +} diff --git a/login/apps/login/locales/zh.json b/login/apps/login/locales/zh.json new file mode 100644 index 0000000000..5a9cb3a4eb --- /dev/null +++ b/login/apps/login/locales/zh.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "返回" + }, + "accounts": { + "title": "账户", + "description": "选择您想使用的账户。", + "addAnother": "添加另一个账户", + "noResults": "未找到账户" + }, + "logout": { + "title": "注销", + "description": "选择您想注销的账户", + "noResults": "未找到账户", + "clear": "注销会话", + "verifiedAt": "最后活动时间:{time}", + "success": { + "title": "注销成功", + "description": "您已成功注销。" + } + }, + "loginname": { + "title": "欢迎回来!", + "description": "请输入您的登录信息。", + "register": "注册新用户", + "submit": "继续" + }, + "password": { + "verify": { + "title": "密码", + "description": "请输入您的密码。", + "resetPassword": "重置密码", + "submit": "继续" + }, + "set": { + "title": "设置密码", + "description": "为您的账户设置密码", + "codeSent": "验证码已发送到您的邮箱。", + "noCodeReceived": "没有收到验证码?", + "resend": "重发验证码", + "submit": "继续" + }, + "change": { + "title": "更改密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "idp": { + "title": "使用 SSO 登录", + "description": "选择以下提供商中的一个进行登录", + "orSignInWith": "或使用以下方式登录", + "signInWithApple": "用 Apple 登录", + "signInWithGoogle": "用 Google 登录", + "signInWithAzureAD": "用 AzureAD 登录", + "signInWithGithub": "用 GitHub 登录", + "signInWithGitlab": "用 GitLab 登录", + "loginSuccess": { + "title": "登录成功", + "description": "您已成功登录!" + }, + "linkingSuccess": { + "title": "账户已链接", + "description": "您已成功链接您的账户!" + }, + "registerSuccess": { + "title": "注册成功", + "description": "您已成功注册!" + }, + "loginError": { + "title": "登录失败", + "description": "登录时发生错误。" + }, + "linkingError": { + "title": "账户链接失败", + "description": "链接账户时发生错误。" + }, + "completeRegister": { + "title": "完成注册", + "description": "完成您的账户注册。" + } + }, + "ldap": { + "title": "使用 LDAP 登录", + "description": "请输入您的 LDAP 凭据。", + "username": "用户名", + "password": "密码", + "submit": "继续" + }, + "mfa": { + "verify": { + "title": "验证您的身份", + "description": "选择以下的一个因素。", + "noResults": "没有可设置的第二因素。" + }, + "set": { + "title": "设置双因素认证", + "description": "选择以下的一个第二因素。", + "skip": "跳过" + } + }, + "otp": { + "verify": { + "title": "验证双因素", + "totpDescription": "请输入认证应用程序中的验证码。", + "smsDescription": "输入通过短信收到的验证码。", + "emailDescription": "输入通过电子邮件收到的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "submit": "继续" + }, + "set": { + "title": "设置双因素认证", + "totpDescription": "使用认证应用程序扫描二维码。", + "smsDescription": "输入您的电话号码以接收短信验证码。", + "emailDescription": "输入您的电子邮箱地址以接收电子邮件验证码。", + "totpRegisterDescription": "扫描二维码或手动导航到URL。", + "submit": "继续" + } + }, + "passkey": { + "verify": { + "title": "使用密钥认证", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "usePassword": "使用密码", + "submit": "继续" + }, + "set": { + "title": "设置密钥", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "info": { + "description": "密钥是在设备上如指纹、Apple FaceID 或类似的认证方法。", + "link": "无密码认证" + }, + "skip": "跳过", + "submit": "继续" + } + }, + "u2f": { + "verify": { + "title": "验证双因素", + "description": "使用您的设备验证帐户。" + }, + "set": { + "title": "设置双因素认证", + "description": "设置设备为第二因素。", + "submit": "继续" + } + }, + "register": { + "methods": { + "passkey": "密钥", + "password": "密码" + }, + "disabled": { + "title": "注册已禁用", + "description": "您的设置不允许注册新用户。" + }, + "missingdata": { + "title": "缺少数据", + "description": "请提供所有必需的数据。" + }, + "title": "注册", + "description": "创建您的 ZITADEL 账户。", + "noMethodAvailableWarning": "没有可用的认证方法。请联系您的系统管理员。", + "selectMethod": "选择您想使用的认证方法", + "agreeTo": "注册即表示您同意条款和条件", + "termsOfService": "服务条款", + "privacyPolicy": "隐私政策", + "submit": "继续", + "orUseIDP": "或使用身份提供者", + "password": { + "title": "设置密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "invite": { + "title": "邀请用户", + "description": "提供您想邀请的用户的电子邮箱地址和姓名。", + "info": "用户将收到一封包含进一步说明的电子邮件。", + "notAllowed": "您的设置不允许邀请用户。", + "submit": "继续", + "success": { + "title": "用户已邀请", + "description": "邮件已成功发送。", + "verified": "用户已被邀请并已验证其电子邮件。", + "notVerifiedYet": "用户已被邀请。他们将收到一封包含进一步说明的电子邮件。", + "submit": "邀请另一位用户" + } + }, + "signedin": { + "title": "欢迎 {user}!", + "description": "您已登录。", + "continue": "继续", + "error": { + "title": "错误", + "description": "登录时发生错误。" + } + }, + "verify": { + "userIdMissing": "未提供用户 ID!", + "successTitle": "用户已验证", + "successDescription": "用户已成功验证。", + "setupAuthenticator": "设置认证器", + "verify": { + "title": "验证用户", + "description": "输入验证邮件中的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "codeSent": "刚刚发送了一封包含验证码的电子邮件。", + "submit": "继续" + } + }, + "authenticator": { + "title": "选择认证方式", + "description": "选择您想使用的认证方法", + "noMethodsAvailable": "没有可用的认证方法", + "allSetup": "您已经设置好了一个认证器!", + "linkWithIDP": "或将其与身份提供者关联" + }, + "device": { + "usercode": { + "title": "设备代码", + "description": "输入代码。", + "submit": "继续" + }, + "request": { + "title": "{appName} 想要连接:", + "description": "{appName} 将访问:", + "disclaimer": "点击“允许”即表示您允许此应用程序和 Zitadel 根据其各自的服务条款和隐私政策使用您的信息。您可以随时撤销此访问权限。", + "submit": "允许", + "deny": "拒绝" + }, + "scope": { + "openid": "验证您的身份。", + "email": "访问您的电子邮件地址。", + "profile": "访问您的完整个人资料信息。", + "offline_access": "允许离线访问您的账户。" + } + }, + "error": { + "noUserCode": "未提供用户代码!", + "noDeviceRequest": "没有找到设备请求。", + "unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。", + "sessionExpired": "当前会话已过期,请重新登录。", + "failedLoading": "加载数据失败,请再试一次。", + "tryagain": "重试" + } +} diff --git a/login/apps/login/next-env-vars.d.ts b/login/apps/login/next-env-vars.d.ts new file mode 100644 index 0000000000..b7a525858c --- /dev/null +++ b/login/apps/login/next-env-vars.d.ts @@ -0,0 +1,33 @@ +declare namespace NodeJS { + interface ProcessEnv { + // Allow any environment variable that matches the pattern + [key: `${string}_AUDIENCE`]: string; // The system api url + [key: `${string}_SYSTEM_USER_ID`]: string; // The service user id + [key: `${string}_SYSTEM_USER_PRIVATE_KEY`]: string; // The service user private key + + AUDIENCE: string; // The fallback system api url + SYSTEM_USER_ID: string; // The fallback service user id + SYSTEM_USER_PRIVATE_KEY: string; // The fallback service user private key + + /** + * The Zitadel API url + */ + ZITADEL_API_URL: string; + + /** + * The service user token + */ + ZITADEL_SERVICE_USER_TOKEN: string; + + /** + * Optional: wheter a user must have verified email + */ + EMAIL_VERIFICATION: string; + + /** + * Optional: custom request headers to be added to every request + * Split by comma, key value pairs separated by colon + */ + CUSTOM_REQUEST_HEADERS?: string; + } +} diff --git a/login/apps/login/next-env.d.ts b/login/apps/login/next-env.d.ts new file mode 100755 index 0000000000..1b3be0840f --- /dev/null +++ b/login/apps/login/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/login/apps/login/next.config.mjs b/login/apps/login/next.config.mjs new file mode 100755 index 0000000000..b84f11a230 --- /dev/null +++ b/login/apps/login/next.config.mjs @@ -0,0 +1,83 @@ +import createNextIntlPlugin from "next-intl/plugin"; +import { DEFAULT_CSP } from "./constants/csp.js"; + +const withNextIntl = createNextIntlPlugin(); + +/** @type {import('next').NextConfig} */ + +const secureHeaders = [ + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "Referrer-Policy", + value: "origin-when-cross-origin", + }, + { + key: "X-Frame-Options", + value: "SAMEORIGIN", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-XSS-Protection", + value: "1; mode=block", + }, + { + key: "Content-Security-Policy", + value: `${DEFAULT_CSP} frame-ancestors 'none'`, + }, + { key: "X-Frame-Options", value: "deny" }, +]; + +const imageRemotePatterns = [ + { + protocol: "http", + hostname: "localhost", + port: "8080", + pathname: "/**", + }, + { + protocol: "https", + hostname: "*.zitadel.*", + port: "", + pathname: "/**", + }, +]; + +if (process.env.ZITADEL_API_URL) { + imageRemotePatterns.push({ + protocol: "https", + hostname: process.env.ZITADEL_API_URL?.replace("https://", "") || "", + port: "", + pathname: "/**", + }); +} + +const nextConfig = { + basePath: process.env.NEXT_PUBLIC_BASE_PATH, + output: process.env.NEXT_OUTPUT_MODE || undefined, + reactStrictMode: true, // Recommended for the `pages` directory, default in `app`. + experimental: { + dynamicIO: true, + }, + images: { + remotePatterns: imageRemotePatterns, + }, + eslint: { + ignoreDuringBuilds: true, + }, + async headers() { + return [ + { + source: "/:path*", + headers: secureHeaders, + }, + ]; + }, +}; + +export default withNextIntl(nextConfig); diff --git a/login/apps/login/package.json b/login/apps/login/package.json new file mode 100644 index 0000000000..f498b912c2 --- /dev/null +++ b/login/apps/login/package.json @@ -0,0 +1,75 @@ +{ + "name": "@zitadel/login", + "private": true, + "type": "module", + "scripts": { + "dev": "pnpm next dev --turbopack", + "test:unit": "pnpm vitest", + "test:unit:standalone": "pnpm test:unit", + "test:unit:watch": "pnpm test:unit --watch", + "lint": "pnpm exec next lint && pnpm exec prettier --check .", + "lint:fix": "pnpm exec prettier --write .", + "lint-staged": "lint-staged", + "build": "pnpm exec next build", + "build:login:standalone": "NEXT_PUBLIC_BASE_PATH=/ui/v2/login NEXT_OUTPUT_MODE=standalone pnpm build", + "start": "pnpm build && pnpm exec next start", + "start:built": "pnpm exec next start", + "clean": "pnpm mock:stop && rm -rf .turbo && rm -rf node_modules && rm -rf .next" + }, + "git": { + "pre-commit": "lint-staged" + }, + "lint-staged": { + "*": "prettier --write --ignore-unknown" + }, + "dependencies": { + "@headlessui/react": "^2.1.9", + "@heroicons/react": "2.1.3", + "@tailwindcss/forms": "0.5.7", + "@vercel/analytics": "^1.2.2", + "@zitadel/client": "workspace:*", + "@zitadel/proto": "workspace:*", + "clsx": "1.2.1", + "copy-to-clipboard": "^3.3.3", + "deepmerge": "^4.3.1", + "lucide-react": "0.469.0", + "moment": "^2.29.4", + "next": "15.4.0-canary.86", + "next-intl": "^3.25.1", + "next-themes": "^0.2.1", + "nice-grpc": "2.0.1", + "qrcode.react": "^3.1.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-hook-form": "7.39.5", + "tinycolor2": "1.4.2", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@bufbuild/buf": "^1.53.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@types/ms": "2.1.0", + "@types/node": "^22.14.1", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "@types/tinycolor2": "1.4.3", + "@types/uuid": "^10.0.0", + "@vercel/git-hooks": "1.0.0", + "@zitadel/eslint-config": "workspace:*", + "@zitadel/prettier-config": "workspace:*", + "@zitadel/tailwind-config": "workspace:*", + "@zitadel/tsconfig": "workspace:*", + "autoprefixer": "10.4.21", + "grpc-tools": "1.13.0", + "jsdom": "^26.1.0", + "lint-staged": "15.5.1", + "make-dir-cli": "4.0.0", + "postcss": "8.5.3", + "prettier-plugin-tailwindcss": "0.6.11", + "sass": "^1.87.0", + "tailwindcss": "3.4.14", + "ts-proto": "^2.7.0", + "typescript": "^5.8.3" + } +} diff --git a/login/apps/login/postcss.config.cjs b/login/apps/login/postcss.config.cjs new file mode 100644 index 0000000000..12a703d900 --- /dev/null +++ b/login/apps/login/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/login/apps/login/prettier.config.mjs b/login/apps/login/prettier.config.mjs new file mode 100644 index 0000000000..6df557c2fd --- /dev/null +++ b/login/apps/login/prettier.config.mjs @@ -0,0 +1 @@ +export { default } from "@zitadel/prettier-config"; diff --git a/login/apps/login/public/checkbox.svg b/login/apps/login/public/checkbox.svg new file mode 100644 index 0000000000..94a3298ae6 --- /dev/null +++ b/login/apps/login/public/checkbox.svg @@ -0,0 +1 @@ + diff --git a/login/apps/login/public/favicon.ico b/login/apps/login/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a901eddc34fa1384048e3f1242c82cd36483ecca GIT binary patch literal 15086 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�bhF5v64Dt2EDBsZ?#{)Beuy)ZdRlbmh{*C=HWGotxL9FwYpRU;(c#Imw(ha>hZ%{>AyxY=nMAnDW5*{U)}gYR={!B zRnjvX;xUo3p)!6L$F83_ev!eM1$#EukpxD@de%39%)#G}WhC@wem~(`GVcgeQ{V4C Zp8@y!i|+AhUN`5hro1PVQx_h1{|6%=p(X$T literal 0 HcmV?d00001 diff --git a/login/apps/login/public/favicon/android-chrome-192x192.png b/login/apps/login/public/favicon/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..f22bd442e631e91a1941f4f9d8b37c1ea4181ed1 GIT binary patch literal 17828 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 Hu0mjftWRU2 literal 0 HcmV?d00001 diff --git a/login/apps/login/public/favicon/android-chrome-512x512.png b/login/apps/login/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..6987ed11b45db3179879b2e0892a7afcdd891249 GIT binary patch literal 137768 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%0Alqi6e`R9*-RHY{A1J_h!rpC3ru6ZTA) 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