From 9b8cb3f53e33b7298307e2a1d1b9d87d6723cc75 Mon Sep 17 00:00:00 2001 From: Timshel Date: Sat, 31 May 2025 11:59:47 +0200 Subject: [PATCH] Minor improvements --- SSO.md | 65 +++++++++---------- playwright/.env.template | 6 +- playwright/README.md | 13 +--- .../{vaultwarden => warden}/Dockerfile | 9 +-- .../compose/{vaultwarden => warden}/build.sh | 0 playwright/docker-compose.yml | 2 +- playwright/global-utils.ts | 18 +++-- playwright/test.env | 28 ++++---- playwright/tests/collection.spec.ts | 6 +- playwright/tests/login.smtp.spec.ts | 26 ++++---- playwright/tests/login.spec.ts | 13 ++-- playwright/tests/organization.smtp.spec.ts | 8 +-- playwright/tests/organization.spec.ts | 4 +- playwright/tests/setups/user.ts | 6 +- playwright/tests/sso_login.smtp.spec.ts | 6 +- playwright/tests/sso_login.spec.ts | 14 ++-- .../tests/sso_organization.smtp.spec.ts | 47 ++++---------- playwright/tests/sso_organization.spec.ts | 62 ++++-------------- 18 files changed, 135 insertions(+), 198 deletions(-) rename playwright/compose/{vaultwarden => warden}/Dockerfile (81%) rename playwright/compose/{vaultwarden => warden}/build.sh (100%) diff --git a/SSO.md b/SSO.md index 0fe57fab..079deafc 100644 --- a/SSO.md +++ b/SSO.md @@ -12,42 +12,42 @@ This introduces another way to control who can use the vault without having to u The following configurations are available - - `SSO_ENABLED` : Activate the SSO - - `SSO_ONLY` : disable email+Master password authentication - - `SSO_SIGNUPS_MATCH_EMAIL`: On SSO Signup if a user with a matching email already exists make the association (default `true`) - - `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`: Allow unknown email verification status (default `false`). Allowing this with `SSO_SIGNUPS_MATCH_EMAIL` open potential account takeover. - - `SSO_AUTHORITY` : the OpenID Connect Discovery endpoint of your SSO - - Should not include the `/.well-known/openid-configuration` part and no trailing `/` - - $SSO_AUTHORITY/.well-known/openid-configuration should return the a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse - - `SSO_SCOPES` : Optional, allow to override scopes if needed (default `"email profile"`) - - `SSO_AUTHORIZE_EXTRA_PARAMS` : Optional, allow to add extra parameter to the authorize redirection (default `""`) - - `SSO_PKCE`: Activate PKCE for the Auth Code flow (default `true`). - - `SSO_AUDIENCE_TRUSTED`: Optional, Regex to trust additional audience for the IdToken (`client_id` is always trusted). Use single quote when writing the regex: `'^$'`. - - `SSO_CLIENT_ID` : Client Id - - `SSO_CLIENT_SECRET` : Client Secret - - `SSO_MASTER_PASSWORD_POLICY`: Optional Master password policy (`enforceOnLogin` is not supported). - - `SSO_AUTH_ONLY_NOT_SESSION`: Enable to use SSO only for authentication not session lifecycle - - `SSO_CLIENT_CACHE_EXPIRATION`: Cache calls to the discovery endpoint, duration in seconds, `0` to disable (default `0`); - - `SSO_DEBUG_TOKENS`: Log all tokens (default `false`, `LOG_LEVEL=debug` is required) +- `SSO_ENABLED` : Activate the SSO +- `SSO_ONLY` : disable email+Master password authentication +- `SSO_SIGNUPS_MATCH_EMAIL`: On SSO Signup if a user with a matching email already exists make the association (default `true`) +- `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`: Allow unknown email verification status (default `false`). Allowing this with `SSO_SIGNUPS_MATCH_EMAIL` open potential account takeover. +- `SSO_AUTHORITY` : the OpenID Connect Discovery endpoint of your SSO + - Should not include the `/.well-known/openid-configuration` part and no trailing `/` + - $SSO_AUTHORITY/.well-known/openid-configuration should return the a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse +- `SSO_SCOPES` : Optional, allow to override scopes if needed (default `"email profile"`) +- `SSO_AUTHORIZE_EXTRA_PARAMS` : Optional, allow to add extra parameter to the authorize redirection (default `""`) +- `SSO_PKCE`: Activate PKCE for the Auth Code flow (default `true`). +- `SSO_AUDIENCE_TRUSTED`: Optional, Regex to trust additional audience for the IdToken (`client_id` is always trusted). Use single quote when writing the regex: `'^$'`. +- `SSO_CLIENT_ID` : Client Id +- `SSO_CLIENT_SECRET` : Client Secret +- `SSO_MASTER_PASSWORD_POLICY`: Optional Master password policy (`enforceOnLogin` is not supported). +- `SSO_AUTH_ONLY_NOT_SESSION`: Enable to use SSO only for authentication not session lifecycle +- `SSO_CLIENT_CACHE_EXPIRATION`: Cache calls to the discovery endpoint, duration in seconds, `0` to disable (default `0`); +- `SSO_DEBUG_TOKENS`: Log all tokens for easier debugging (default `false`, `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` need to be set) The callback url is : `https://your.domain/identity/connect/oidc-signin` ## Account and Email handling When logging in with SSO an identifier (`{iss}/{sub}` claims from the IdToken) is saved in a separate table (`sso_users`). -This is used to link to the SSO provider identifier without changing the default Vaultwarden user `uuid`. This is needed because: +This is used to link to the SSO provider identifier without changing the default user `uuid`. This is needed because: - - Storing the SSO identifier is important to prevent account takeover due to email change. - - We can't use the identifier as the User uuid since it's way longer (Max 255 chars for the `sub` part, cf [spec](https://openid.net/specs/openid-connect-core-1_0.html#IDToken)). - - We want to be able to associate existing account based on `email` but only when the user logs in for the first time (controlled by `SSO_SIGNUPS_MATCH_EMAIL`). - - We need to be able to associate with existing stub account, such as the one created when inviting a user to an org (association is possible only if the user does not have a private key). +- Storing the SSO identifier is important to prevent account takeover due to email change. +- We can't use the identifier as the User uuid since it's way longer (Max 255 chars for the `sub` part, cf [spec](https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken)). +- We want to be able to associate existing account based on `email` but only when the user logs in for the first time (controlled by `SSO_SIGNUPS_MATCH_EMAIL`). +- We need to be able to associate with existing stub account, such as the one created when inviting a user to an org (association is possible only if the user does not have a private key). Additionally: - - Signup to Vaultwarden will be blocked if the Provider reports the email as `unverified`. - - Changing the email needs to be done by the user since it requires updating the `key`. - On login if the email returned by the provider is not the one saved in Vaultwarden an email will be sent to the user to ask him to update it. - - If set `SIGNUPS_DOMAINS_WHITELIST` is applied on SSO signup and when attempting to change the email. +- Signup will be blocked if the Provider reports the email as `unverified`. +- Changing the email needs to be done by the user since it requires updating the `key`. + On login if the email returned by the provider is not the one saved an email will be sent to the user to ask him to update it. +- If set `SIGNUPS_DOMAINS_WHITELIST` is applied on SSO signup and when attempting to change the email. This means that if you ever need to change the provider url or the provider itself; you'll have to first delete the association then ensure that `SSO_SIGNUPS_MATCH_EMAIL` is activated to allow a new association. @@ -94,7 +94,7 @@ As mentioned in the Google example setting too high of a value has diminishing r ## Keycloak -Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `VaultWarden` front-end expiration detection which is also set at `5min`. +Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `Bitwarden` front-end expiration detection which is also set at `5min`. \ At the realm level @@ -115,11 +115,10 @@ If you want to run a testing instance of Keycloak the Playwright [docker-compose \ More details on how to use it in [README.md](playwright/README.md#openid-connect-test-setup). - ## Auth0 Not working due to the following issue https://github.com/ramosbugs/openidconnect-rs/issues/23 (they appear not to follow the spec). -A feature flag is available to bypass the issue but since it's a compile time feature you will have to patch `Vaultwarden` with something like: +A feature flag is available to bypass the issue but since it's a compile time feature you will have to patch with something like: ```patch diff --git a/Cargo.toml b/Cargo.toml @@ -148,7 +147,7 @@ Config will look like: ## Authentik -Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `VaultWarden` front-end expiration detection which is also set at `5min`. +Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `Bitwarden` front-end expiration detection which is also set at `5min`. \ To change the tokens expiration go to `Applications / Providers / Edit / Advanced protocol settings`. @@ -208,7 +207,7 @@ Nothing specific should work with just `SSO_AUTHORITY`, `SSO_CLIENT_ID` and `SSO 1. Create an "App registration" in [Entra ID](https://entra.microsoft.com/) following [Identity | Applications | App registrations](https://entra.microsoft.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade/quickStartType//sourceType/Microsoft_AAD_IAM). 2. From the "Overview" of your "App registration", you'll need the "Directory (tenant) ID" for the `SSO_AUTHORITY` variable and the "Application (client) ID" as the `SSO_CLIENT_ID` value. 3. In "Certificates & Secrets" create an "App secret" , you'll need the "Secret Value" for the `SSO_CLIENT_SECRET` variable. -4. In "Authentication" add as "Web Redirect URI". +4. In "Authentication" add as "Web Redirect URI". 5. In "API Permissions" make sure you have `profile`, `email` and `offline_access` listed under "API / Permission name" (`offline_access` is required, otherwise no refresh_token is returned, see ). Only the v2 endpoint is compliant with the OpenID spec, see and . @@ -270,8 +269,8 @@ Config will look like: Session lifetime is dependant on refresh token and access token returned after calling your SSO token endpoint (grant type `authorization_code`). If no refresh token is returned then the session will be limited to the access token lifetime. -Tokens are not persisted in VaultWarden but wrapped in JWT tokens and returned to the application (The `refresh_token` and `access_token` values returned by VW `identity/connect/token` endpoint). -Note that VaultWarden will always return a `refresh_token` for compatibility reasons with the web front and it presence does not indicate that a refresh token was returned by your SSO (But you can decode its value with and then check if the `token` field contain anything). +Tokens are not persisted in the server but wrapped in JWT tokens and returned to the application (The `refresh_token` and `access_token` values returned by VW `identity/connect/token` endpoint). +Note that the server will always return a `refresh_token` for compatibility reasons with the web front and it presence does not indicate that a refresh token was returned by your SSO (But you can decode its value with and then check if the `token` field contain anything). With a refresh token present, activity in the application will trigger a refresh of the access token when it's close to expiration ([5min](https://github.com/bitwarden/clients/blob/0bcb45ed5caa990abaff735553a5046e85250f24/libs/common/src/auth/services/token.service.ts#L126) in web client). diff --git a/playwright/.env.template b/playwright/.env.template index e7b1b4aa..fd007b52 100644 --- a/playwright/.env.template +++ b/playwright/.env.template @@ -29,7 +29,7 @@ KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} KC_HTTP_HOST=127.0.0.1 KC_HTTP_PORT=8080 -# Script parameters (use Keycloak and VaultWarden config too) +# Script parameters (use Keycloak and Vaultwarden config too) TEST_REALM=test DUMMY_REALM=dummy DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} @@ -45,8 +45,8 @@ I_REALLY_WANT_VOLATILE_STORAGE=true SSO_ENABLED=true SSO_ONLY=false -SSO_CLIENT_ID=VaultWarden -SSO_CLIENT_SECRET=VaultWarden +SSO_CLIENT_ID=warden +SSO_CLIENT_SECRET=warden SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} SMTP_HOST=127.0.0.1 diff --git a/playwright/README.md b/playwright/README.md index c470fbae..3f8bef40 100644 --- a/playwright/README.md +++ b/playwright/README.md @@ -143,18 +143,7 @@ You can run just `Keycloak` with `--profile keycloak`: ```bash > docker compose --profile keycloak --env-file .env up ``` - -When running with a local VaultWarden and the default `web-vault` you'll need to make the SSO button visible using : - -```bash -sed -i 's#a\[routerlink="/sso"\],##' web-vault/app/main.*.css -``` - -Otherwise you'll need to reveal the SSO login button using the debug console (F12) - - ```js - document.querySelector('a[routerlink="/sso"]').style.setProperty("display", "inline-block", "important"); - ``` +When running with a local VaultWarden, you can use a front-end build from [dani-garcia/bw_web_builds](https://github.com/dani-garcia/bw_web_builds/releases). ## Rebuilding the Vaultwarden diff --git a/playwright/compose/vaultwarden/Dockerfile b/playwright/compose/warden/Dockerfile similarity index 81% rename from playwright/compose/vaultwarden/Dockerfile rename to playwright/compose/warden/Dockerfile index 2772dc0a..93d12b3b 100644 --- a/playwright/compose/vaultwarden/Dockerfile +++ b/playwright/compose/warden/Dockerfile @@ -1,4 +1,4 @@ -FROM playwright_oidc_vaultwarden_prebuilt AS vaultwarden +FROM playwright_oidc_vaultwarden_prebuilt AS prebuilt FROM node:18-bookworm AS build @@ -8,7 +8,8 @@ ARG COMMIT_HASH ENV REPO_URL=$REPO_URL ENV COMMIT_HASH=$COMMIT_HASH -COPY --from=vaultwarden /web-vault /web-vault +COPY --from=prebuilt /web-vault /web-vault + COPY build.sh /build.sh RUN /build.sh @@ -32,8 +33,8 @@ RUN mkdir /data && \ # and the binary from the "build" stage to the current stage WORKDIR / -COPY --from=vaultwarden /start.sh . -COPY --from=vaultwarden /vaultwarden . +COPY --from=prebuilt /start.sh . +COPY --from=prebuilt /vaultwarden . COPY --from=build /web-vault ./web-vault ENTRYPOINT ["/start.sh"] diff --git a/playwright/compose/vaultwarden/build.sh b/playwright/compose/warden/build.sh similarity index 100% rename from playwright/compose/vaultwarden/build.sh rename to playwright/compose/warden/build.sh diff --git a/playwright/docker-compose.yml b/playwright/docker-compose.yml index 2adeb61a..3e56477c 100644 --- a/playwright/docker-compose.yml +++ b/playwright/docker-compose.yml @@ -15,7 +15,7 @@ services: image: playwright_oidc_vaultwarden-${ENV:-dev} network_mode: "host" build: - context: compose/vaultwarden + context: compose/warden dockerfile: Dockerfile args: REPO_URL: ${PW_WV_REPO_URL:-} diff --git a/playwright/global-utils.ts b/playwright/global-utils.ts index ae723c7f..38aa2226 100644 --- a/playwright/global-utils.ts +++ b/playwright/global-utils.ts @@ -170,7 +170,7 @@ function dbConfig(testInfo: TestInfo){ /** * All parameters passed in `env` need to be added to the docker-compose.yml **/ -export async function startVaultwarden(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) { +export async function startVault(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) { if( resetDB ){ switch(testInfo.project.name) { case "postgres": @@ -195,14 +195,18 @@ export async function startVaultwarden(browser: Browser, testInfo: TestInfo, env console.log(`Vaultwarden running on: ${process.env.DOMAIN}`); } -export async function stopVaultwarden() { - console.log(`Vaultwarden stopping`); - execSync(`docker compose --profile playwright --env-file test.env stop Vaultwarden`); +export async function stopVault(force: boolean = false) { + if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { + console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`); + } else { + console.log(`Vaultwarden stopping`); + execSync(`docker compose --profile playwright --env-file test.env stop Vaultwarden`); + } } -export async function restartVaultwarden(page: Page, testInfo: TestInfo, env, resetDB: Boolean = true) { - stopVaultwarden(); - return startVaultwarden(page.context().browser(), testInfo, env, resetDB); +export async function restartVault(page: Page, testInfo: TestInfo, env, resetDB: Boolean = true) { + stopVault(true); + return startVault(page.context().browser(), testInfo, env, resetDB); } export async function checkNotification(page: Page, hasText: string) { diff --git a/playwright/test.env b/playwright/test.env index d5a38f3e..4524fcb6 100644 --- a/playwright/test.env +++ b/playwright/test.env @@ -11,7 +11,7 @@ DOCKER_BUILDKIT=1 # Playwright Config # ##################### PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false} -VAULTWARDEN_SMTP_FROM=vaultwarden@playwright.test +PW_SMTP_FROM=vaultwarden@playwright.test ##################### # Maildev Config # @@ -61,8 +61,8 @@ SMTP_PORT=${MAILDEV_SMTP_PORT} SMTP_FROM_NAME=Vaultwarden SMTP_TIMEOUT=5 -SSO_CLIENT_ID=VaultWarden -SSO_CLIENT_SECRET=VaultWarden +SSO_CLIENT_ID=warden +SSO_CLIENT_SECRET=warden SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} SSO_DEBUG_TOKENS=true @@ -70,24 +70,24 @@ SSO_DEBUG_TOKENS=true # Docker MariaDb container# ########################### MARIADB_PORT=3307 -MARIADB_ROOT_PASSWORD=vaultwarden -MARIADB_USER=vaultwarden -MARIADB_PASSWORD=vaultwarden -MARIADB_DATABASE=vaultwarden +MARIADB_ROOT_PASSWORD=warden +MARIADB_USER=warden +MARIADB_PASSWORD=warden +MARIADB_DATABASE=warden ########################### # Docker Mysql container# ########################### MYSQL_PORT=3309 -MYSQL_ROOT_PASSWORD=vaultwarden -MYSQL_USER=vaultwarden -MYSQL_PASSWORD=vaultwarden -MYSQL_DATABASE=vaultwarden +MYSQL_ROOT_PASSWORD=warden +MYSQL_USER=warden +MYSQL_PASSWORD=warden +MYSQL_DATABASE=warden ############################ # Docker Postgres container# ############################ POSTGRES_PORT=5433 -POSTGRES_USER=vaultwarden -POSTGRES_PASSWORD=vaultwarden -POSTGRES_DB=vaultwarden +POSTGRES_USER=warden +POSTGRES_PASSWORD=warden +POSTGRES_DB=warden diff --git a/playwright/tests/collection.spec.ts b/playwright/tests/collection.spec.ts index 8e1991f5..786a4644 100644 --- a/playwright/tests/collection.spec.ts +++ b/playwright/tests/collection.spec.ts @@ -1,16 +1,16 @@ import { test, expect, type TestInfo } from '@playwright/test'; import * as utils from "../global-utils"; -import { createAccount, logUser } from './setups/user'; +import { createAccount } from './setups/user'; let users = utils.loadEnv(); test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVaultwarden(browser, testInfo); + await utils.startVault(browser, testInfo); }); test.afterAll('Teardown', async ({}) => { - utils.stopVaultwarden(); + utils.stopVault(); }); test('Create', async ({ page }) => { diff --git a/playwright/tests/login.smtp.spec.ts b/playwright/tests/login.smtp.spec.ts index 3005df71..e7f56afe 100644 --- a/playwright/tests/login.smtp.spec.ts +++ b/playwright/tests/login.smtp.spec.ts @@ -17,14 +17,14 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { await mailserver.listen(); - await utils.startVaultwarden(browser, testInfo, { + await utils.startVault(browser, testInfo, { SMTP_HOST: process.env.MAILDEV_HOST, - SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, + SMTP_FROM: process.env.PW_SMTP_FROM, }); }); test.afterAll('Teardown', async ({}) => { - utils.stopVaultwarden(); + utils.stopVault(); if( mailserver ){ await mailserver.close(); } @@ -32,7 +32,9 @@ test.afterAll('Teardown', async ({}) => { test('Account creation', async ({ page }) => { const mailBuffer = mailserver.buffer(users.user1.email); + await createAccount(test, page, users.user1, mailBuffer); + mailBuffer.close(); }); @@ -49,7 +51,7 @@ test('Login', async ({ context, page }) => { await utils.checkNotification(page, 'Check your email inbox for a verification link'); const verify = await mailBuffer.next((m) => m.subject === "Verify Your Email"); - expect(verify.from[0]?.address).toBe(process.env.VAULTWARDEN_SMTP_FROM); + expect(verify.from[0]?.address).toBe(process.env.PW_SMTP_FROM); const page2 = await context.newPage(); await page2.setContent(verify.html); @@ -63,7 +65,7 @@ test('Login', async ({ context, page }) => { mailBuffer.close(); }); -test('Activaite 2fa', async ({ context, page }) => { +test('Activate 2fa', async ({ page }) => { const emails = mailserver.buffer(users.user1.email); await logUser(test, page, users.user1); @@ -73,7 +75,7 @@ test('Activaite 2fa', async ({ context, page }) => { emails.close(); }); -test('2fa', async ({ context, page }) => { +test('2fa', async ({ page }) => { const emails = mailserver.buffer(users.user1.email); await test.step('login', async () => { @@ -84,16 +86,12 @@ test('2fa', async ({ context, page }) => { await page.getByLabel('Master password').fill(users.user1.password); await page.getByRole('button', { name: 'Log in with master password' }).click(); - const codeMail = await emails.next((mail) => mail.subject === "Vaultwarden Login Verification Code"); - const page2 = await context.newPage(); - await page2.setContent(codeMail.html); - const code = await page2.getByTestId("2fa").innerText(); - await page2.close(); - - await page.getByLabel('Verification code').fill(code); + await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); + const code = await retrieveEmailCode(test, page, emails); + await page.getByLabel(/Verification code/).fill(code); await page.getByRole('button', { name: 'Continue' }).click(); - await expect(page).toHaveTitle(/Vaultwarden Web/); + await expect(page).toHaveTitle(/Vaults/); }) await disableEmail(test, page, users.user1); diff --git a/playwright/tests/login.spec.ts b/playwright/tests/login.spec.ts index fe9d20ae..ba21cc00 100644 --- a/playwright/tests/login.spec.ts +++ b/playwright/tests/login.spec.ts @@ -9,11 +9,11 @@ let users = utils.loadEnv(); let totp; test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVaultwarden(browser, testInfo, {}); + await utils.startVault(browser, testInfo, {}); }); -test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { - utils.stopVaultwarden(testInfo); +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); }); test('Account creation', async ({ page }) => { @@ -24,7 +24,7 @@ test('Master password login', async ({ page }) => { await logUser(test, page, users.user1); }); -test('Authenticator 2fa', async ({ context, page }) => { +test('Authenticator 2fa', async ({ page }) => { await logUser(test, page, users.user1); let totp = await activateTOTP(test, page, users.user1); @@ -36,7 +36,7 @@ test('Authenticator 2fa', async ({ context, page }) => { }); await test.step('login', async () => { - let timestamp = Date.now(); // Need to use the next token + let timestamp = Date.now(); // Needed to use the next token timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; await page.getByLabel(/Email address/).fill(users.user1.email); @@ -44,7 +44,8 @@ test('Authenticator 2fa', async ({ context, page }) => { await page.getByLabel('Master password').fill(users.user1.password); await page.getByRole('button', { name: 'Log in with master password' }).click(); - await page.getByLabel('Verification code').fill(totp.generate({timestamp})); + await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); + await page.getByLabel(/Verification code/).fill(totp.generate({timestamp})); await page.getByRole('button', { name: 'Continue' }).click(); await expect(page).toHaveTitle(/Vaultwarden Web/); diff --git a/playwright/tests/organization.smtp.spec.ts b/playwright/tests/organization.smtp.spec.ts index b9070196..070cdb89 100644 --- a/playwright/tests/organization.smtp.spec.ts +++ b/playwright/tests/organization.smtp.spec.ts @@ -17,9 +17,9 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { await mailServer.listen(); - await utils.startVaultwarden(browser, testInfo, { + await utils.startVault(browser, testInfo, { SMTP_HOST: process.env.MAILDEV_HOST, - SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, + SMTP_FROM: process.env.PW_SMTP_FROM, }); mail1Buffer = mailServer.buffer(users.user1.email); @@ -28,7 +28,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { }); test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { - utils.stopVaultwarden(testInfo); + utils.stopVault(testInfo); [mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); }); @@ -110,6 +110,6 @@ test('Confirm invited user', async ({ page }) => { test('Organization is visible', async ({ page }) => { await logUser(test, page, users.user2, mail2Buffer); - await page.getByLabel('vault: Test').click(); + await page.getByRole('button', { name: 'vault: Test', exact: true }).click(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); }); diff --git a/playwright/tests/organization.spec.ts b/playwright/tests/organization.spec.ts index c2b9d69d..4e644fa7 100644 --- a/playwright/tests/organization.spec.ts +++ b/playwright/tests/organization.spec.ts @@ -8,11 +8,11 @@ import { createAccount, logUser } from './setups/user'; let users = utils.loadEnv(); test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVaultwarden(browser, testInfo); + await utils.startVault(browser, testInfo); }); test.afterAll('Teardown', async ({}) => { - utils.stopVaultwarden(); + utils.stopVault(); }); test('Invite', async ({ page }) => { diff --git a/playwright/tests/setups/user.ts b/playwright/tests/setups/user.ts index cce93bff..c894d986 100644 --- a/playwright/tests/setups/user.ts +++ b/playwright/tests/setups/user.ts @@ -1,4 +1,5 @@ -import { expect, type Browser,Page } from '@playwright/test'; +import { expect, type Browser, Page } from '@playwright/test'; + import { type MailBuffer } from 'maildev'; import * as utils from '../../global-utils'; @@ -28,6 +29,7 @@ export async function createAccount(test, page: Page, user: { email: string, nam if( mailBuffer ){ await expect(mailBuffer.next((m) => m.subject === "Welcome")).resolves.toBeDefined(); + await expect(mailBuffer.next((m) => m.subject === "New Device Logged In From Firefox")).resolves.toBeDefined(); } }); } @@ -47,7 +49,7 @@ export async function logUser(test, page: Page, user: { email: string, password: await expect(page).toHaveTitle(/Vaultwarden Web/); if( mailBuffer ){ - await expect(mailBuffer.next((m) => m.subject === 'New Device Logged In From Firefox')).resolves.toBeDefined(); + await expect(mailBuffer.next((m) => m.subject === "New Device Logged In From Firefox")).resolves.toBeDefined(); } }); } diff --git a/playwright/tests/sso_login.smtp.spec.ts b/playwright/tests/sso_login.smtp.spec.ts index 02a1d8d3..7a615cd6 100644 --- a/playwright/tests/sso_login.smtp.spec.ts +++ b/playwright/tests/sso_login.smtp.spec.ts @@ -17,16 +17,16 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { await mailserver.listen(); - await utils.startVaultwarden(browser, testInfo, { + await utils.startVault(browser, testInfo, { SSO_ENABLED: true, SSO_ONLY: false, SMTP_HOST: process.env.MAILDEV_HOST, - SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, + SMTP_FROM: process.env.PW_SMTP_FROM, }); }); test.afterAll('Teardown', async ({}) => { - utils.stopVaultwarden(); + utils.stopVault(); if( mailserver ){ await mailserver.close(); } diff --git a/playwright/tests/sso_login.spec.ts b/playwright/tests/sso_login.spec.ts index 562ccb09..b4817bed 100644 --- a/playwright/tests/sso_login.spec.ts +++ b/playwright/tests/sso_login.spec.ts @@ -7,14 +7,14 @@ import * as utils from "../global-utils"; let users = utils.loadEnv(); test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVaultwarden(browser, testInfo, { + await utils.startVault(browser, testInfo, { SSO_ENABLED: true, SSO_ONLY: false }); }); test.afterAll('Teardown', async ({}) => { - utils.stopVaultwarden(); + utils.stopVault(); }); test('Account creation using SSO', async ({ page }) => { @@ -51,13 +51,14 @@ test('SSO login with TOTP 2fa', async ({ page }) => { }); test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) => { - await utils.restartVaultwarden(page, testInfo, { + await utils.restartVault(page, testInfo, { SSO_ENABLED: true, SSO_ONLY: true }, false); // Landing page await page.goto('/'); + await page.getByLabel(/Email address/).fill(users.user1.email); // Check that SSO login is available await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1); @@ -75,7 +76,7 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) = test('No SSO login', async ({ page }, testInfo: TestInfo) => { - await utils.restartVaultwarden(page, testInfo, { + await utils.restartVault(page, testInfo, { SSO_ENABLED: false }, false); @@ -84,5 +85,10 @@ test('No SSO login', async ({ page }, testInfo: TestInfo) => { await page.getByLabel(/Email address/).fill(users.user1.email); // No SSO button (rely on a correct selector checked in previous test) + await page.getByLabel('Master password'); await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0); + + // Can continue to Master password + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('button', { name: /Log in with master password/ })).toHaveCount(1); }); diff --git a/playwright/tests/sso_organization.smtp.spec.ts b/playwright/tests/sso_organization.smtp.spec.ts index 36bfcef7..cbe13da3 100644 --- a/playwright/tests/sso_organization.smtp.spec.ts +++ b/playwright/tests/sso_organization.smtp.spec.ts @@ -2,6 +2,7 @@ import { test, expect, type TestInfo } from '@playwright/test'; import { MailDev } from 'maildev'; import * as utils from "../global-utils"; +import * as orgs from './setups/orgs'; import { logNewUser, logUser } from './setups/sso'; let users = utils.loadEnv(); @@ -16,9 +17,9 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { await mailServer.listen(); - await utils.startVaultwarden(browser, testInfo, { + await utils.startVault(browser, testInfo, { SMTP_HOST: process.env.MAILDEV_HOST, - SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, + SMTP_FROM: process.env.PW_SMTP_FROM, SSO_ENABLED: true, SSO_ONLY: true, }); @@ -29,7 +30,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { }); test.afterAll('Teardown', async ({}) => { - utils.stopVaultwarden(); + utils.stopVault(); [mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); }); @@ -40,39 +41,15 @@ test('Create user3', async ({ page }) => { test('Invite users', async ({ page }) => { await logNewUser(test, page, users.user1, { mailBuffer: mail1Buffer }); - await test.step('Create Org', async () => { - await page.getByRole('link', { name: 'New organisation' }).click(); - await page.getByLabel('Organisation name (required)').fill('Test'); - await page.getByRole('button', { name: 'Submit' }).click(); - await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); - }); - - await test.step('Invite user2', async () => { - await page.getByRole('button', { name: 'Invite member' }).click(); - await page.getByLabel('Email (required)').fill(users.user2.email); - await page.getByRole('tab', { name: 'Collections' }).click(); - await page.getByLabel('Permission').selectOption('edit'); - await page.getByLabel('Select collections').click(); - await page.getByLabel('Options list').getByText('Default collection').click(); - await page.getByRole('button', { name: 'Save' }).click(); - await utils.checkNotification(page, 'User(s) invited'); - }); - - await test.step('Invite user3', async () => { - await page.getByRole('button', { name: 'Invite member' }).click(); - await page.getByLabel('Email (required)').fill(users.user3.email); - await page.getByRole('tab', { name: 'Collections' }).click(); - await page.getByLabel('Permission').selectOption('edit'); - await page.getByLabel('Select collections').click(); - await page.getByLabel('Options list').getByText('Default collection').click(); - await page.getByRole('button', { name: 'Save' }).click(); - await utils.checkNotification(page, 'User(s) invited'); - }); + await orgs.create(test, page, '/Test'); + await orgs.members(test, page, '/Test'); + await orgs.invite(test, page, '/Test', users.user2.email); + await orgs.invite(test, page, '/Test', users.user3.email); }); test('invited with new account', async ({ page }) => { const link = await test.step('Extract email link', async () => { - const invited = await mail2Buffer.next((m) => m.subject === "Join Test"); + const invited = await mail2Buffer.next((m) => m.subject === "Join /Test"); await page.setContent(invited.html); return await page.getByTestId("invite").getAttribute("href"); }); @@ -104,13 +81,13 @@ test('invited with new account', async ({ page }) => { await test.step('Check mails', async () => { await expect(mail2Buffer.next((m) => m.subject.includes("New Device Logged"))).resolves.toBeDefined(); - await expect(mail1Buffer.next((m) => m.subject === "Invitation to Test accepted")).resolves.toBeDefined(); + await expect(mail1Buffer.next((m) => m.subject === "Invitation to /Test accepted")).resolves.toBeDefined(); }); }); test('invited with existing account', async ({ page }) => { const link = await test.step('Extract email link', async () => { - const invited = await mail3Buffer.next((m) => m.subject === "Join Test"); + const invited = await mail3Buffer.next((m) => m.subject === "Join /Test"); await page.setContent(invited.html); return await page.getByTestId("invite").getAttribute("href"); }); @@ -139,6 +116,6 @@ test('invited with existing account', async ({ page }) => { await test.step('Check mails', async () => { await expect(mail3Buffer.next((m) => m.subject.includes("New Device Logged"))).resolves.toBeDefined(); - await expect(mail1Buffer.next((m) => m.subject === "Invitation to Test accepted")).resolves.toBeDefined(); + await expect(mail1Buffer.next((m) => m.subject === "Invitation to /Test accepted")).resolves.toBeDefined(); }); }); diff --git a/playwright/tests/sso_organization.spec.ts b/playwright/tests/sso_organization.spec.ts index 360369af..68ead019 100644 --- a/playwright/tests/sso_organization.spec.ts +++ b/playwright/tests/sso_organization.spec.ts @@ -2,19 +2,20 @@ import { test, expect, type TestInfo } from '@playwright/test'; import { MailDev } from 'maildev'; import * as utils from "../global-utils"; +import * as orgs from './setups/orgs'; import { logNewUser, logUser } from './setups/sso'; let users = utils.loadEnv(); test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVaultwarden(browser, testInfo, { + await utils.startVault(browser, testInfo, { SSO_ENABLED: true, SSO_ONLY: true, }); }); test.afterAll('Teardown', async ({}) => { - utils.stopVaultwarden(); + utils.stopVault(); }); test('Create user3', async ({ page }) => { @@ -24,43 +25,11 @@ test('Create user3', async ({ page }) => { test('Invite users', async ({ page }) => { await logNewUser(test, page, users.user1); - await test.step('Create Org', async () => { - await page.getByRole('link', { name: 'New organisation' }).click(); - await page.getByLabel('Organisation name (required)').fill('Test'); - await page.getByRole('button', { name: 'Submit' }).click(); - await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); - }); - - await test.step('Invite user2', async () => { - await page.getByRole('button', { name: 'Invite member' }).click(); - await page.getByLabel('Email (required)').fill(users.user2.email); - await page.getByRole('tab', { name: 'Collections' }).click(); - await page.getByLabel('Permission').selectOption('edit'); - await page.getByLabel('Select collections').click(); - await page.getByLabel('Options list').getByText('Default collection').click(); - await page.getByRole('button', { name: 'Save' }).click(); - await utils.checkNotification(page, 'User(s) invited'); - await expect(page.getByRole('row', { name: users.user2.email })).toHaveText(/Invited/); - }); - - await test.step('Invite user3', async () => { - await page.getByRole('button', { name: 'Invite member' }).click(); - await page.getByLabel('Email (required)').fill(users.user3.email); - await page.getByRole('tab', { name: 'Collections' }).click(); - await page.getByLabel('Permission').selectOption('edit'); - await page.getByLabel('Select collections').click(); - await page.getByLabel('Options list').getByText('Default collection').click(); - await page.getByRole('button', { name: 'Save' }).click(); - await utils.checkNotification(page, 'User(s) invited'); - await expect(page.getByRole('row', { name: users.user3.name })).toHaveText(/Needs confirmation/); - }); - - await test.step('Confirm existing user3', async () => { - await page.getByRole('row', { name: users.user3.name }).getByLabel('Options').click(); - await page.getByRole('menuitem', { name: 'Confirm' }).click(); - await page.getByRole('button', { name: 'Confirm' }).click(); - await utils.checkNotification(page, 'confirmed'); - }); + await orgs.create(test, page, '/Test'); + await orgs.members(test, page, '/Test'); + await orgs.invite(test, page, '/Test', users.user2.email); + await orgs.invite(test, page, '/Test', users.user3.email); + await orgs.confirm(test, page, '/Test', users.user3.email); }); test('Create invited account', async ({ page }) => { @@ -69,22 +38,13 @@ test('Create invited account', async ({ page }) => { test('Confirm invited user', async ({ page }) => { await logUser(test, page, users.user1); - await page.getByLabel('Switch products').click(); - await page.getByRole('link', { name: ' Admin Console' }).click(); - await page.getByRole('link', { name: 'Members' }).click(); - + await orgs.members(test, page, '/Test'); await expect(page.getByRole('row', { name: users.user2.name })).toHaveText(/Needs confirmation/); - - await test.step('Confirm user2', async () => { - await page.getByRole('row', { name: users.user2.name }).getByLabel('Options').click(); - await page.getByRole('menuitem', { name: 'Confirm' }).click(); - await page.getByRole('button', { name: 'Confirm' }).click(); - await utils.checkNotification(page, 'confirmed'); - }); + await orgs.confirm(test, page, '/Test', users.user2.email); }); test('Organization is visible', async ({ page }) => { await logUser(test, page, users.user2); - await page.getByLabel('vault: Test').click(); + await page.getByLabel('vault: /Test').click(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); });