Browse Source

Minor improvements

pull/3899/head
Timshel 3 months ago
parent
commit
9b8cb3f53e
  1. 65
      SSO.md
  2. 6
      playwright/.env.template
  3. 13
      playwright/README.md
  4. 9
      playwright/compose/warden/Dockerfile
  5. 0
      playwright/compose/warden/build.sh
  6. 2
      playwright/docker-compose.yml
  7. 18
      playwright/global-utils.ts
  8. 28
      playwright/test.env
  9. 6
      playwright/tests/collection.spec.ts
  10. 26
      playwright/tests/login.smtp.spec.ts
  11. 13
      playwright/tests/login.spec.ts
  12. 8
      playwright/tests/organization.smtp.spec.ts
  13. 4
      playwright/tests/organization.spec.ts
  14. 6
      playwright/tests/setups/user.ts
  15. 6
      playwright/tests/sso_login.smtp.spec.ts
  16. 14
      playwright/tests/sso_login.spec.ts
  17. 47
      playwright/tests/sso_organization.smtp.spec.ts
  18. 62
      playwright/tests/sso_organization.spec.ts

65
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 The following configurations are available
- `SSO_ENABLED` : Activate the SSO - `SSO_ENABLED` : Activate the SSO
- `SSO_ONLY` : disable email+Master password authentication - `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_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_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 - `SSO_AUTHORITY` : the OpenID Connect Discovery endpoint of your SSO
- Should not include the `/.well-known/openid-configuration` part and no trailing `/` - 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_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_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_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_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_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_ID` : Client Id
- `SSO_CLIENT_SECRET` : Client Secret - `SSO_CLIENT_SECRET` : Client Secret
- `SSO_MASTER_PASSWORD_POLICY`: Optional Master password policy (`enforceOnLogin` is not supported). - `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_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_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_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` The callback url is : `https://your.domain/identity/connect/oidc-signin`
## Account and Email handling ## 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`). 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. - 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 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 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). - 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: Additionally:
- Signup to Vaultwarden will be blocked if the Provider reports the email as `unverified`. - 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`. - 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. 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. - 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 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. 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 ## 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 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). More details on how to use it in [README.md](playwright/README.md#openid-connect-test-setup).
## Auth0 ## Auth0
Not working due to the following issue https://github.com/ramosbugs/openidconnect-rs/issues/23 (they appear not to follow the spec). 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 ```patch
diff --git a/Cargo.toml b/Cargo.toml diff --git a/Cargo.toml b/Cargo.toml
@ -148,7 +147,7 @@ Config will look like:
## Authentik ## 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`. 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). 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. 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. 3. In "Certificates & Secrets" create an "App secret" , you'll need the "Secret Value" for the `SSO_CLIENT_SECRET` variable.
4. In "Authentication" add <https://vaultwarden.example.org/identity/connect/oidc-signin> as "Web Redirect URI". 4. In "Authentication" add <https://warden.example.org/identity/connect/oidc-signin> 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 <https://github.com/MicrosoftDocs/azure-docs/issues/17134>). 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 <https://github.com/MicrosoftDocs/azure-docs/issues/17134>).
Only the v2 endpoint is compliant with the OpenID spec, see <https://github.com/MicrosoftDocs/azure-docs/issues/38427> and <https://github.com/ramosbugs/openidconnect-rs/issues/122>. Only the v2 endpoint is compliant with the OpenID spec, see <https://github.com/MicrosoftDocs/azure-docs/issues/38427> and <https://github.com/ramosbugs/openidconnect-rs/issues/122>.
@ -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`). 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. 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). 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 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 <https://jwt.io> and then check if the `token` field contain anything). 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 <https://jwt.io> 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). 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).

6
playwright/.env.template

@ -29,7 +29,7 @@ KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
KC_HTTP_HOST=127.0.0.1 KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8080 KC_HTTP_PORT=8080
# Script parameters (use Keycloak and VaultWarden config too) # Script parameters (use Keycloak and Vaultwarden config too)
TEST_REALM=test TEST_REALM=test
DUMMY_REALM=dummy DUMMY_REALM=dummy
DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} 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_ENABLED=true
SSO_ONLY=false SSO_ONLY=false
SSO_CLIENT_ID=VaultWarden SSO_CLIENT_ID=warden
SSO_CLIENT_SECRET=VaultWarden SSO_CLIENT_SECRET=warden
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
SMTP_HOST=127.0.0.1 SMTP_HOST=127.0.0.1

13
playwright/README.md

@ -143,18 +143,7 @@ You can run just `Keycloak` with `--profile keycloak`:
```bash ```bash
> docker compose --profile keycloak --env-file .env up > docker compose --profile keycloak --env-file .env up
``` ```
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).
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");
```
## Rebuilding the Vaultwarden ## Rebuilding the Vaultwarden

9
playwright/compose/vaultwarden/Dockerfile → 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 FROM node:18-bookworm AS build
@ -8,7 +8,8 @@ ARG COMMIT_HASH
ENV REPO_URL=$REPO_URL ENV REPO_URL=$REPO_URL
ENV COMMIT_HASH=$COMMIT_HASH ENV COMMIT_HASH=$COMMIT_HASH
COPY --from=vaultwarden /web-vault /web-vault COPY --from=prebuilt /web-vault /web-vault
COPY build.sh /build.sh COPY build.sh /build.sh
RUN /build.sh RUN /build.sh
@ -32,8 +33,8 @@ RUN mkdir /data && \
# and the binary from the "build" stage to the current stage # and the binary from the "build" stage to the current stage
WORKDIR / WORKDIR /
COPY --from=vaultwarden /start.sh . COPY --from=prebuilt /start.sh .
COPY --from=vaultwarden /vaultwarden . COPY --from=prebuilt /vaultwarden .
COPY --from=build /web-vault ./web-vault COPY --from=build /web-vault ./web-vault
ENTRYPOINT ["/start.sh"] ENTRYPOINT ["/start.sh"]

0
playwright/compose/vaultwarden/build.sh → playwright/compose/warden/build.sh

2
playwright/docker-compose.yml

@ -15,7 +15,7 @@ services:
image: playwright_oidc_vaultwarden-${ENV:-dev} image: playwright_oidc_vaultwarden-${ENV:-dev}
network_mode: "host" network_mode: "host"
build: build:
context: compose/vaultwarden context: compose/warden
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
REPO_URL: ${PW_WV_REPO_URL:-} REPO_URL: ${PW_WV_REPO_URL:-}

18
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 * 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 ){ if( resetDB ){
switch(testInfo.project.name) { switch(testInfo.project.name) {
case "postgres": case "postgres":
@ -195,14 +195,18 @@ export async function startVaultwarden(browser: Browser, testInfo: TestInfo, env
console.log(`Vaultwarden running on: ${process.env.DOMAIN}`); console.log(`Vaultwarden running on: ${process.env.DOMAIN}`);
} }
export async function stopVaultwarden() { export async function stopVault(force: boolean = false) {
console.log(`Vaultwarden stopping`); if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
execSync(`docker compose --profile playwright --env-file test.env stop Vaultwarden`); 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) { export async function restartVault(page: Page, testInfo: TestInfo, env, resetDB: Boolean = true) {
stopVaultwarden(); stopVault(true);
return startVaultwarden(page.context().browser(), testInfo, env, resetDB); return startVault(page.context().browser(), testInfo, env, resetDB);
} }
export async function checkNotification(page: Page, hasText: string) { export async function checkNotification(page: Page, hasText: string) {

28
playwright/test.env

@ -11,7 +11,7 @@ DOCKER_BUILDKIT=1
# Playwright Config # # Playwright Config #
##################### #####################
PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false} PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false}
VAULTWARDEN_SMTP_FROM=vaultwarden@playwright.test PW_SMTP_FROM=vaultwarden@playwright.test
##################### #####################
# Maildev Config # # Maildev Config #
@ -61,8 +61,8 @@ SMTP_PORT=${MAILDEV_SMTP_PORT}
SMTP_FROM_NAME=Vaultwarden SMTP_FROM_NAME=Vaultwarden
SMTP_TIMEOUT=5 SMTP_TIMEOUT=5
SSO_CLIENT_ID=VaultWarden SSO_CLIENT_ID=warden
SSO_CLIENT_SECRET=VaultWarden SSO_CLIENT_SECRET=warden
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
SSO_DEBUG_TOKENS=true SSO_DEBUG_TOKENS=true
@ -70,24 +70,24 @@ SSO_DEBUG_TOKENS=true
# Docker MariaDb container# # Docker MariaDb container#
########################### ###########################
MARIADB_PORT=3307 MARIADB_PORT=3307
MARIADB_ROOT_PASSWORD=vaultwarden MARIADB_ROOT_PASSWORD=warden
MARIADB_USER=vaultwarden MARIADB_USER=warden
MARIADB_PASSWORD=vaultwarden MARIADB_PASSWORD=warden
MARIADB_DATABASE=vaultwarden MARIADB_DATABASE=warden
########################### ###########################
# Docker Mysql container# # Docker Mysql container#
########################### ###########################
MYSQL_PORT=3309 MYSQL_PORT=3309
MYSQL_ROOT_PASSWORD=vaultwarden MYSQL_ROOT_PASSWORD=warden
MYSQL_USER=vaultwarden MYSQL_USER=warden
MYSQL_PASSWORD=vaultwarden MYSQL_PASSWORD=warden
MYSQL_DATABASE=vaultwarden MYSQL_DATABASE=warden
############################ ############################
# Docker Postgres container# # Docker Postgres container#
############################ ############################
POSTGRES_PORT=5433 POSTGRES_PORT=5433
POSTGRES_USER=vaultwarden POSTGRES_USER=warden
POSTGRES_PASSWORD=vaultwarden POSTGRES_PASSWORD=warden
POSTGRES_DB=vaultwarden POSTGRES_DB=warden

6
playwright/tests/collection.spec.ts

@ -1,16 +1,16 @@
import { test, expect, type TestInfo } from '@playwright/test'; import { test, expect, type TestInfo } from '@playwright/test';
import * as utils from "../global-utils"; import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user'; import { createAccount } from './setups/user';
let users = utils.loadEnv(); let users = utils.loadEnv();
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVaultwarden(browser, testInfo); await utils.startVault(browser, testInfo);
}); });
test.afterAll('Teardown', async ({}) => { test.afterAll('Teardown', async ({}) => {
utils.stopVaultwarden(); utils.stopVault();
}); });
test('Create', async ({ page }) => { test('Create', async ({ page }) => {

26
playwright/tests/login.smtp.spec.ts

@ -17,14 +17,14 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await mailserver.listen(); await mailserver.listen();
await utils.startVaultwarden(browser, testInfo, { await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST, SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, SMTP_FROM: process.env.PW_SMTP_FROM,
}); });
}); });
test.afterAll('Teardown', async ({}) => { test.afterAll('Teardown', async ({}) => {
utils.stopVaultwarden(); utils.stopVault();
if( mailserver ){ if( mailserver ){
await mailserver.close(); await mailserver.close();
} }
@ -32,7 +32,9 @@ test.afterAll('Teardown', async ({}) => {
test('Account creation', async ({ page }) => { test('Account creation', async ({ page }) => {
const mailBuffer = mailserver.buffer(users.user1.email); const mailBuffer = mailserver.buffer(users.user1.email);
await createAccount(test, page, users.user1, mailBuffer); await createAccount(test, page, users.user1, mailBuffer);
mailBuffer.close(); mailBuffer.close();
}); });
@ -49,7 +51,7 @@ test('Login', async ({ context, page }) => {
await utils.checkNotification(page, 'Check your email inbox for a verification link'); await utils.checkNotification(page, 'Check your email inbox for a verification link');
const verify = await mailBuffer.next((m) => m.subject === "Verify Your Email"); 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(); const page2 = await context.newPage();
await page2.setContent(verify.html); await page2.setContent(verify.html);
@ -63,7 +65,7 @@ test('Login', async ({ context, page }) => {
mailBuffer.close(); mailBuffer.close();
}); });
test('Activaite 2fa', async ({ context, page }) => { test('Activate 2fa', async ({ page }) => {
const emails = mailserver.buffer(users.user1.email); const emails = mailserver.buffer(users.user1.email);
await logUser(test, page, users.user1); await logUser(test, page, users.user1);
@ -73,7 +75,7 @@ test('Activaite 2fa', async ({ context, page }) => {
emails.close(); emails.close();
}); });
test('2fa', async ({ context, page }) => { test('2fa', async ({ page }) => {
const emails = mailserver.buffer(users.user1.email); const emails = mailserver.buffer(users.user1.email);
await test.step('login', async () => { 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.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click(); await page.getByRole('button', { name: 'Log in with master password' }).click();
const codeMail = await emails.next((mail) => mail.subject === "Vaultwarden Login Verification Code"); await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
const page2 = await context.newPage(); const code = await retrieveEmailCode(test, page, emails);
await page2.setContent(codeMail.html); await page.getByLabel(/Verification code/).fill(code);
const code = await page2.getByTestId("2fa").innerText();
await page2.close();
await page.getByLabel('Verification code').fill(code);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaults/);
}) })
await disableEmail(test, page, users.user1); await disableEmail(test, page, users.user1);

13
playwright/tests/login.spec.ts

@ -9,11 +9,11 @@ let users = utils.loadEnv();
let totp; let totp;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVaultwarden(browser, testInfo, {}); await utils.startVault(browser, testInfo, {});
}); });
test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { test.afterAll('Teardown', async ({}) => {
utils.stopVaultwarden(testInfo); utils.stopVault();
}); });
test('Account creation', async ({ page }) => { test('Account creation', async ({ page }) => {
@ -24,7 +24,7 @@ test('Master password login', async ({ page }) => {
await logUser(test, page, users.user1); await logUser(test, page, users.user1);
}); });
test('Authenticator 2fa', async ({ context, page }) => { test('Authenticator 2fa', async ({ page }) => {
await logUser(test, page, users.user1); await logUser(test, page, users.user1);
let totp = await activateTOTP(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 () => { 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; timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
await page.getByLabel(/Email address/).fill(users.user1.email); 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.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click(); 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 page.getByRole('button', { name: 'Continue' }).click();
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);

8
playwright/tests/organization.smtp.spec.ts

@ -17,9 +17,9 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await mailServer.listen(); await mailServer.listen();
await utils.startVaultwarden(browser, testInfo, { await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST, 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); mail1Buffer = mailServer.buffer(users.user1.email);
@ -28,7 +28,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
}); });
test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { test.afterAll('Teardown', async ({}, testInfo: TestInfo) => {
utils.stopVaultwarden(testInfo); utils.stopVault(testInfo);
[mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); [mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close());
}); });
@ -110,6 +110,6 @@ test('Confirm invited user', async ({ page }) => {
test('Organization is visible', async ({ page }) => { test('Organization is visible', async ({ page }) => {
await logUser(test, page, users.user2, mail2Buffer); 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(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
}); });

4
playwright/tests/organization.spec.ts

@ -8,11 +8,11 @@ import { createAccount, logUser } from './setups/user';
let users = utils.loadEnv(); let users = utils.loadEnv();
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVaultwarden(browser, testInfo); await utils.startVault(browser, testInfo);
}); });
test.afterAll('Teardown', async ({}) => { test.afterAll('Teardown', async ({}) => {
utils.stopVaultwarden(); utils.stopVault();
}); });
test('Invite', async ({ page }) => { test('Invite', async ({ page }) => {

6
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 { type MailBuffer } from 'maildev';
import * as utils from '../../global-utils'; import * as utils from '../../global-utils';
@ -28,6 +29,7 @@ export async function createAccount(test, page: Page, user: { email: string, nam
if( mailBuffer ){ if( mailBuffer ){
await expect(mailBuffer.next((m) => m.subject === "Welcome")).resolves.toBeDefined(); 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/); await expect(page).toHaveTitle(/Vaultwarden Web/);
if( mailBuffer ){ 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();
} }
}); });
} }

6
playwright/tests/sso_login.smtp.spec.ts

@ -17,16 +17,16 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await mailserver.listen(); await mailserver.listen();
await utils.startVaultwarden(browser, testInfo, { await utils.startVault(browser, testInfo, {
SSO_ENABLED: true, SSO_ENABLED: true,
SSO_ONLY: false, SSO_ONLY: false,
SMTP_HOST: process.env.MAILDEV_HOST, SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, SMTP_FROM: process.env.PW_SMTP_FROM,
}); });
}); });
test.afterAll('Teardown', async ({}) => { test.afterAll('Teardown', async ({}) => {
utils.stopVaultwarden(); utils.stopVault();
if( mailserver ){ if( mailserver ){
await mailserver.close(); await mailserver.close();
} }

14
playwright/tests/sso_login.spec.ts

@ -7,14 +7,14 @@ import * as utils from "../global-utils";
let users = utils.loadEnv(); let users = utils.loadEnv();
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVaultwarden(browser, testInfo, { await utils.startVault(browser, testInfo, {
SSO_ENABLED: true, SSO_ENABLED: true,
SSO_ONLY: false SSO_ONLY: false
}); });
}); });
test.afterAll('Teardown', async ({}) => { test.afterAll('Teardown', async ({}) => {
utils.stopVaultwarden(); utils.stopVault();
}); });
test('Account creation using SSO', async ({ page }) => { 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) => { test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) => {
await utils.restartVaultwarden(page, testInfo, { await utils.restartVault(page, testInfo, {
SSO_ENABLED: true, SSO_ENABLED: true,
SSO_ONLY: true SSO_ONLY: true
}, false); }, false);
// Landing page // Landing page
await page.goto('/'); await page.goto('/');
await page.getByLabel(/Email address/).fill(users.user1.email);
// Check that SSO login is available // Check that SSO login is available
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1); 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) => { test('No SSO login', async ({ page }, testInfo: TestInfo) => {
await utils.restartVaultwarden(page, testInfo, { await utils.restartVault(page, testInfo, {
SSO_ENABLED: false SSO_ENABLED: false
}, false); }, false);
@ -84,5 +85,10 @@ test('No SSO login', async ({ page }, testInfo: TestInfo) => {
await page.getByLabel(/Email address/).fill(users.user1.email); await page.getByLabel(/Email address/).fill(users.user1.email);
// No SSO button (rely on a correct selector checked in previous test) // 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); 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);
}); });

47
playwright/tests/sso_organization.smtp.spec.ts

@ -2,6 +2,7 @@ import { test, expect, type TestInfo } from '@playwright/test';
import { MailDev } from 'maildev'; import { MailDev } from 'maildev';
import * as utils from "../global-utils"; import * as utils from "../global-utils";
import * as orgs from './setups/orgs';
import { logNewUser, logUser } from './setups/sso'; import { logNewUser, logUser } from './setups/sso';
let users = utils.loadEnv(); let users = utils.loadEnv();
@ -16,9 +17,9 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await mailServer.listen(); await mailServer.listen();
await utils.startVaultwarden(browser, testInfo, { await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST, SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, SMTP_FROM: process.env.PW_SMTP_FROM,
SSO_ENABLED: true, SSO_ENABLED: true,
SSO_ONLY: true, SSO_ONLY: true,
}); });
@ -29,7 +30,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
}); });
test.afterAll('Teardown', async ({}) => { test.afterAll('Teardown', async ({}) => {
utils.stopVaultwarden(); utils.stopVault();
[mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); [mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close());
}); });
@ -40,39 +41,15 @@ test('Create user3', async ({ page }) => {
test('Invite users', async ({ page }) => { test('Invite users', async ({ page }) => {
await logNewUser(test, page, users.user1, { mailBuffer: mail1Buffer }); await logNewUser(test, page, users.user1, { mailBuffer: mail1Buffer });
await test.step('Create Org', async () => { await orgs.create(test, page, '/Test');
await page.getByRole('link', { name: 'New organisation' }).click(); await orgs.members(test, page, '/Test');
await page.getByLabel('Organisation name (required)').fill('Test'); await orgs.invite(test, page, '/Test', users.user2.email);
await page.getByRole('button', { name: 'Submit' }).click(); await orgs.invite(test, page, '/Test', users.user3.email);
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');
});
}); });
test('invited with new account', async ({ page }) => { test('invited with new account', async ({ page }) => {
const link = await test.step('Extract email link', async () => { 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); await page.setContent(invited.html);
return await page.getByTestId("invite").getAttribute("href"); 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 test.step('Check mails', async () => {
await expect(mail2Buffer.next((m) => m.subject.includes("New Device Logged"))).resolves.toBeDefined(); 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 }) => { test('invited with existing account', async ({ page }) => {
const link = await test.step('Extract email link', async () => { 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); await page.setContent(invited.html);
return await page.getByTestId("invite").getAttribute("href"); 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 test.step('Check mails', async () => {
await expect(mail3Buffer.next((m) => m.subject.includes("New Device Logged"))).resolves.toBeDefined(); 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();
}); });
}); });

62
playwright/tests/sso_organization.spec.ts

@ -2,19 +2,20 @@ import { test, expect, type TestInfo } from '@playwright/test';
import { MailDev } from 'maildev'; import { MailDev } from 'maildev';
import * as utils from "../global-utils"; import * as utils from "../global-utils";
import * as orgs from './setups/orgs';
import { logNewUser, logUser } from './setups/sso'; import { logNewUser, logUser } from './setups/sso';
let users = utils.loadEnv(); let users = utils.loadEnv();
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVaultwarden(browser, testInfo, { await utils.startVault(browser, testInfo, {
SSO_ENABLED: true, SSO_ENABLED: true,
SSO_ONLY: true, SSO_ONLY: true,
}); });
}); });
test.afterAll('Teardown', async ({}) => { test.afterAll('Teardown', async ({}) => {
utils.stopVaultwarden(); utils.stopVault();
}); });
test('Create user3', async ({ page }) => { test('Create user3', async ({ page }) => {
@ -24,43 +25,11 @@ test('Create user3', async ({ page }) => {
test('Invite users', async ({ page }) => { test('Invite users', async ({ page }) => {
await logNewUser(test, page, users.user1); await logNewUser(test, page, users.user1);
await test.step('Create Org', async () => { await orgs.create(test, page, '/Test');
await page.getByRole('link', { name: 'New organisation' }).click(); await orgs.members(test, page, '/Test');
await page.getByLabel('Organisation name (required)').fill('Test'); await orgs.invite(test, page, '/Test', users.user2.email);
await page.getByRole('button', { name: 'Submit' }).click(); await orgs.invite(test, page, '/Test', users.user3.email);
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); await orgs.confirm(test, page, '/Test', users.user3.email);
});
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');
});
}); });
test('Create invited account', async ({ page }) => { test('Create invited account', async ({ page }) => {
@ -69,22 +38,13 @@ test('Create invited account', async ({ page }) => {
test('Confirm invited user', async ({ page }) => { test('Confirm invited user', async ({ page }) => {
await logUser(test, page, users.user1); await logUser(test, page, users.user1);
await page.getByLabel('Switch products').click(); await orgs.members(test, page, '/Test');
await page.getByRole('link', { name: ' Admin Console' }).click();
await page.getByRole('link', { name: 'Members' }).click();
await expect(page.getByRole('row', { name: users.user2.name })).toHaveText(/Needs confirmation/); await expect(page.getByRole('row', { name: users.user2.name })).toHaveText(/Needs confirmation/);
await orgs.confirm(test, page, '/Test', users.user2.email);
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');
});
}); });
test('Organization is visible', async ({ page }) => { test('Organization is visible', async ({ page }) => {
await logUser(test, page, users.user2); 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(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
}); });

Loading…
Cancel
Save