111 changed files with 7881 additions and 395 deletions
@ -0,0 +1 @@ |
|||
DROP TABLE sso_nonce; |
@ -0,0 +1,4 @@ |
|||
CREATE TABLE sso_nonce ( |
|||
nonce CHAR(36) NOT NULL PRIMARY KEY, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
|||
); |
@ -0,0 +1 @@ |
|||
ALTER TABLE users_organizations DROP COLUMN invited_by_email; |
@ -0,0 +1 @@ |
|||
ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; |
@ -0,0 +1,6 @@ |
|||
DROP TABLE IF EXISTS sso_nonce; |
|||
|
|||
CREATE TABLE sso_nonce ( |
|||
nonce CHAR(36) NOT NULL PRIMARY KEY, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
|||
); |
@ -0,0 +1,8 @@ |
|||
DROP TABLE IF EXISTS sso_nonce; |
|||
|
|||
CREATE TABLE sso_nonce ( |
|||
state VARCHAR(512) NOT NULL PRIMARY KEY, |
|||
nonce TEXT NOT NULL, |
|||
redirect_uri TEXT NOT NULL, |
|||
created_at TIMESTAMP NOT NULL DEFAULT now() |
|||
); |
@ -0,0 +1,8 @@ |
|||
DROP TABLE IF EXISTS sso_nonce; |
|||
|
|||
CREATE TABLE sso_nonce ( |
|||
state VARCHAR(512) NOT NULL PRIMARY KEY, |
|||
nonce TEXT NOT NULL, |
|||
redirect_uri TEXT NOT NULL, |
|||
created_at TIMESTAMP NOT NULL DEFAULT now() |
|||
); |
@ -0,0 +1,9 @@ |
|||
DROP TABLE IF EXISTS sso_nonce; |
|||
|
|||
CREATE TABLE sso_nonce ( |
|||
state VARCHAR(512) NOT NULL PRIMARY KEY, |
|||
nonce TEXT NOT NULL, |
|||
verifier TEXT, |
|||
redirect_uri TEXT NOT NULL, |
|||
created_at TIMESTAMP NOT NULL DEFAULT now() |
|||
); |
@ -0,0 +1 @@ |
|||
DROP TABLE IF EXISTS sso_users; |
@ -0,0 +1,7 @@ |
|||
CREATE TABLE sso_users ( |
|||
user_uuid CHAR(36) NOT NULL PRIMARY KEY, |
|||
identifier VARCHAR(768) NOT NULL UNIQUE, |
|||
created_at TIMESTAMP NOT NULL DEFAULT now(), |
|||
|
|||
FOREIGN KEY(user_uuid) REFERENCES users(uuid) |
|||
); |
@ -0,0 +1,2 @@ |
|||
ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`; |
|||
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; |
@ -0,0 +1 @@ |
|||
DROP TABLE sso_nonce; |
@ -0,0 +1,4 @@ |
|||
CREATE TABLE sso_nonce ( |
|||
nonce CHAR(36) NOT NULL PRIMARY KEY, |
|||
created_at TIMESTAMP NOT NULL DEFAULT now() |
|||
); |
@ -0,0 +1 @@ |
|||
ALTER TABLE users_organizations DROP COLUMN invited_by_email; |
@ -0,0 +1 @@ |
|||
ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; |
@ -0,0 +1,6 @@ |
|||
DROP TABLE sso_nonce; |
|||
|
|||
CREATE TABLE sso_nonce ( |
|||
nonce CHAR(36) NOT NULL PRIMARY KEY, |
|||
created_at TIMESTAMP NOT NULL DEFAULT now() |
|||
); |
@ -0,0 +1,8 @@ |
|||
DROP TABLE sso_nonce; |
|||
|
|||
CREATE TABLE sso_nonce ( |
|||
state TEXT NOT NULL PRIMARY KEY, |
|||
nonce TEXT NOT NULL, |
|||
redirect_uri TEXT NOT NULL, |
|||
created_at TIMESTAMP NOT NULL DEFAULT now() |
|||
); |
@ -0,0 +1,8 @@ |
|||
DROP TABLE IF EXISTS sso_nonce; |
|||
|
|||
CREATE TABLE sso_nonce ( |
|||
state TEXT NOT NULL PRIMARY KEY, |
|||
nonce TEXT NOT NULL, |
|||
redirect_uri TEXT NOT NULL, |
|||
created_at TIMESTAMP NOT NULL DEFAULT now() |
|||
); |
@ -0,0 +1,9 @@ |
|||
DROP TABLE IF EXISTS sso_nonce; |
|||
|
|||
CREATE TABLE sso_nonce ( |
|||
state TEXT NOT NULL PRIMARY KEY, |
|||
nonce TEXT NOT NULL, |
|||
verifier TEXT, |
|||
redirect_uri TEXT NOT NULL, |
|||
created_at TIMESTAMP NOT NULL DEFAULT now() |
|||
); |
@ -0,0 +1 @@ |
|||
DROP TABLE IF EXISTS sso_users; |
@ -0,0 +1,7 @@ |
|||
CREATE TABLE sso_users ( |
|||
user_uuid CHAR(36) NOT NULL PRIMARY KEY, |
|||
identifier TEXT NOT NULL UNIQUE, |
|||
created_at TIMESTAMP NOT NULL DEFAULT now(), |
|||
|
|||
FOREIGN KEY(user_uuid) REFERENCES users(uuid) |
|||
); |
@ -0,0 +1,3 @@ |
|||
ALTER TABLE sso_users |
|||
DROP CONSTRAINT "sso_users_user_uuid_fkey", |
|||
ADD CONSTRAINT "sso_users_user_uuid_fkey" FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; |
@ -0,0 +1 @@ |
|||
DROP TABLE sso_nonce; |
@ -0,0 +1,4 @@ |
|||
CREATE TABLE sso_nonce ( |
|||
nonce CHAR(36) NOT NULL PRIMARY KEY, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
|||
); |
@ -0,0 +1 @@ |
|||
ALTER TABLE users_organizations DROP COLUMN invited_by_email; |
@ -0,0 +1 @@ |
|||
ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; |
@ -0,0 +1,6 @@ |
|||
DROP TABLE sso_nonce; |
|||
|
|||
CREATE TABLE sso_nonce ( |
|||
nonce CHAR(36) NOT NULL PRIMARY KEY, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
|||
); |
@ -0,0 +1,8 @@ |
|||
DROP TABLE sso_nonce; |
|||
|
|||
CREATE TABLE sso_nonce ( |
|||
state TEXT NOT NULL PRIMARY KEY, |
|||
nonce TEXT NOT NULL, |
|||
redirect_uri TEXT NOT NULL, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
|||
); |
@ -0,0 +1,8 @@ |
|||
DROP TABLE IF EXISTS sso_nonce; |
|||
|
|||
CREATE TABLE sso_nonce ( |
|||
state TEXT NOT NULL PRIMARY KEY, |
|||
nonce TEXT NOT NULL, |
|||
redirect_uri TEXT NOT NULL, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
|||
); |
@ -0,0 +1,9 @@ |
|||
DROP TABLE IF EXISTS sso_nonce; |
|||
|
|||
CREATE TABLE sso_nonce ( |
|||
state TEXT NOT NULL PRIMARY KEY, |
|||
nonce TEXT NOT NULL, |
|||
verifier TEXT, |
|||
redirect_uri TEXT NOT NULL, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
|||
); |
@ -0,0 +1 @@ |
|||
DROP TABLE IF EXISTS sso_users; |
@ -0,0 +1,7 @@ |
|||
CREATE TABLE sso_users ( |
|||
user_uuid CHAR(36) NOT NULL PRIMARY KEY, |
|||
identifier TEXT NOT NULL UNIQUE, |
|||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
|
|||
FOREIGN KEY(user_uuid) REFERENCES users(uuid) |
|||
); |
@ -0,0 +1,9 @@ |
|||
DROP TABLE IF EXISTS sso_users; |
|||
|
|||
CREATE TABLE sso_users ( |
|||
user_uuid CHAR(36) NOT NULL PRIMARY KEY, |
|||
identifier TEXT NOT NULL UNIQUE, |
|||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
|
|||
FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE |
|||
); |
@ -0,0 +1,64 @@ |
|||
################################# |
|||
### Conf to run dev instances ### |
|||
################################# |
|||
ENV=dev |
|||
DC_ENV_FILE=.env |
|||
COMPOSE_IGNORE_ORPHANS=True |
|||
DOCKER_BUILDKIT=1 |
|||
|
|||
################ |
|||
# Users Config # |
|||
################ |
|||
TEST_USER=test |
|||
TEST_USER_PASSWORD=${TEST_USER} |
|||
TEST_USER_MAIL=${TEST_USER}@yopmail.com |
|||
|
|||
TEST_USER2=test2 |
|||
TEST_USER2_PASSWORD=${TEST_USER2} |
|||
TEST_USER2_MAIL=${TEST_USER2}@yopmail.com |
|||
|
|||
TEST_USER3=test3 |
|||
TEST_USER3_PASSWORD=${TEST_USER3} |
|||
TEST_USER3_MAIL=${TEST_USER3}@yopmail.com |
|||
|
|||
################### |
|||
# Keycloak Config # |
|||
################### |
|||
KEYCLOAK_ADMIN=admin |
|||
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} |
|||
KC_HTTP_HOST=127.0.0.1 |
|||
KC_HTTP_PORT=8080 |
|||
|
|||
# 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} |
|||
|
|||
###################### |
|||
# Vaultwarden Config # |
|||
###################### |
|||
ROCKET_ADDRESS=0.0.0.0 |
|||
ROCKET_PORT=8000 |
|||
DOMAIN=http://127.0.0.1:${ROCKET_PORT} |
|||
LOG_LEVEL=info,oidcwarden::sso=debug |
|||
I_REALLY_WANT_VOLATILE_STORAGE=true |
|||
|
|||
SSO_ENABLED=true |
|||
SSO_ONLY=false |
|||
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 |
|||
SMTP_PORT=1025 |
|||
SMTP_SECURITY=off |
|||
SMTP_TIMEOUT=5 |
|||
SMTP_FROM=vaultwarden@test |
|||
SMTP_FROM_NAME=Vaultwarden |
|||
|
|||
######################################################## |
|||
# DUMMY values for docker-compose to stop bothering us # |
|||
######################################################## |
|||
MARIADB_PORT=3305 |
|||
MYSQL_PORT=3307 |
|||
POSTGRES_PORT=5432 |
@ -0,0 +1,6 @@ |
|||
logs |
|||
node_modules/ |
|||
/test-results/ |
|||
/playwright-report/ |
|||
/playwright/.cache/ |
|||
temp |
@ -0,0 +1,166 @@ |
|||
# Integration tests |
|||
|
|||
This allows running integration tests using [Playwright](https://playwright.dev/). |
|||
\ |
|||
It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance. |
|||
|
|||
## Install |
|||
|
|||
This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/). |
|||
Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in containers. |
|||
|
|||
### Running Playwright outside docker |
|||
|
|||
It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change. |
|||
You'll additionally need `nodejs` then run: |
|||
|
|||
```bash |
|||
npm install |
|||
npx playwright install-deps |
|||
npx playwright install firefox |
|||
``` |
|||
|
|||
## Usage |
|||
|
|||
To run all the tests: |
|||
|
|||
```bash |
|||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright |
|||
``` |
|||
|
|||
To force a rebuild of the Playwright image: |
|||
```bash |
|||
DOCKER_BUILDKIT=1 docker compose --env-file test.env build Playwright |
|||
``` |
|||
|
|||
To access the ui to easily run test individually and debug if needed (will not work in docker): |
|||
|
|||
```bash |
|||
npx playwright test --ui |
|||
``` |
|||
|
|||
### DB |
|||
|
|||
Projects are configured to allow to run tests only on specific database. |
|||
\ |
|||
You can use: |
|||
|
|||
```bash |
|||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mariadb |
|||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mysql |
|||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=postgres |
|||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite |
|||
``` |
|||
|
|||
### SSO |
|||
|
|||
To run the SSO tests: |
|||
|
|||
```bash |
|||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project sso-sqlite |
|||
``` |
|||
|
|||
### Keep services running |
|||
|
|||
If you want you can keep the Db and Keycloak runnning (states are not impacted by the tests): |
|||
|
|||
```bash |
|||
PW_KEEP_SERVICE_RUNNNING=true npx playwright test |
|||
``` |
|||
|
|||
### Running specific tests |
|||
|
|||
To run a whole file you can : |
|||
|
|||
```bash |
|||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts |
|||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login |
|||
``` |
|||
|
|||
To run only a specifc test (It might fail if it has dependency): |
|||
|
|||
```bash |
|||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation" |
|||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts:16 |
|||
``` |
|||
|
|||
## Writing scenario |
|||
|
|||
When creating new scenario use the recorder to more easily identify elements (in general try to rely on visible hint to identify elements and not hidden ids). |
|||
This does not start the server, you will need to start it manually. |
|||
|
|||
```bash |
|||
npx playwright codegen "http://127.0.0.1:8000" |
|||
``` |
|||
|
|||
## Override web-vault |
|||
|
|||
It's possible to change the `web-vault` used by referencing a different `bw_web_builds` commit. |
|||
|
|||
```bash |
|||
export PW_WV_REPO_URL=https://github.com/Timshel/oidc_web_builds.git |
|||
export PW_WV_COMMIT_HASH=8707dc76df3f0cceef2be5bfae37bb29bd17fae6 |
|||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env build Playwright |
|||
``` |
|||
|
|||
# OpenID Connect test setup |
|||
|
|||
Additionally this `docker-compose` template allow to run locally `VaultWarden`, [Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC. |
|||
|
|||
## Setup |
|||
|
|||
This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/). |
|||
First create a copy of `.env.template` as `.env` (This is done to prevent commiting your custom settings, Ex `SMTP_`). |
|||
|
|||
## Usage |
|||
|
|||
Then start the stack (the `profile` is required to run `Vaultwarden`) : |
|||
|
|||
```bash |
|||
> docker compose --profile vaultwarden --env-file .env up |
|||
.... |
|||
keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master |
|||
keycloakSetup_1 | Created new realm with id 'test' |
|||
keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e |
|||
oidc_keycloakSetup_1 exited with code 0 |
|||
``` |
|||
|
|||
Wait until `oidc_keycloakSetup_1 exited with code 0` which indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done). |
|||
|
|||
Then you can access : |
|||
|
|||
- `VaultWarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`. |
|||
- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin` |
|||
- `Maildev` on http://0.0.0.0:1080 |
|||
|
|||
To proceed with an SSO login after you enter the email, on the screen prompting for `Master Password` the SSO button should be visible. |
|||
To use your computer external ip (for example when testing with a phone) you will have to configure `KC_HTTP_HOST` and `DOMAIN`. |
|||
|
|||
## Running only Keycloak |
|||
|
|||
You can run just `Keycloak` with `--profile keycloak`: |
|||
|
|||
```bash |
|||
> 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). |
|||
|
|||
## Rebuilding the Vaultwarden |
|||
|
|||
To force rebuilding the Vaultwarden image you can run |
|||
|
|||
```bash |
|||
docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild Vaultwarden |
|||
``` |
|||
|
|||
## Configuration |
|||
|
|||
All configuration for `keycloak` / `VaultWarden` / `keycloak_setup.sh` can be found in [.env](.env.template). |
|||
The content of the file will be loaded as environment variables in all containers. |
|||
|
|||
- `keycloak` [configuration](https://www.keycloak.org/server/all-config) include `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)). |
|||
- All `VaultWarden` configuration can be set (EX: `SMTP_*`) |
|||
|
|||
## Cleanup |
|||
|
|||
Use `docker compose --profile vaultWarden down`. |
@ -0,0 +1,40 @@ |
|||
FROM docker.io/library/debian:bookworm-slim as build |
|||
|
|||
ENV DEBIAN_FRONTEND=noninteractive |
|||
ARG KEYCLOAK_VERSION |
|||
|
|||
SHELL ["/bin/bash", "-o", "pipefail", "-c"] |
|||
|
|||
RUN apt-get update \ |
|||
&& apt-get install -y ca-certificates curl wget \ |
|||
&& rm -rf /var/lib/apt/lists/* |
|||
|
|||
WORKDIR / |
|||
|
|||
RUN wget -c https://github.com/keycloak/keycloak/releases/download/${KEYCLOAK_VERSION}/keycloak-${KEYCLOAK_VERSION}.tar.gz -O - | tar -xz |
|||
|
|||
FROM docker.io/library/debian:bookworm-slim |
|||
|
|||
ENV DEBIAN_FRONTEND=noninteractive |
|||
ARG KEYCLOAK_VERSION |
|||
|
|||
SHELL ["/bin/bash", "-o", "pipefail", "-c"] |
|||
|
|||
RUN apt-get update \ |
|||
&& apt-get install -y ca-certificates curl wget \ |
|||
&& rm -rf /var/lib/apt/lists/* |
|||
|
|||
ARG JAVA_URL |
|||
ARG JAVA_VERSION |
|||
|
|||
ENV JAVA_VERSION=${JAVA_VERSION} |
|||
|
|||
RUN mkdir -p /opt/openjdk && cd /opt/openjdk \ |
|||
&& wget -c "${JAVA_URL}" -O - | tar -xz |
|||
|
|||
WORKDIR / |
|||
|
|||
COPY setup.sh /setup.sh |
|||
COPY --from=build /keycloak-${KEYCLOAK_VERSION}/bin /opt/keycloak/bin |
|||
|
|||
CMD "/setup.sh" |
@ -0,0 +1,36 @@ |
|||
#!/bin/bash |
|||
|
|||
export PATH=/opt/keycloak/bin:/opt/openjdk/jdk-${JAVA_VERSION}/bin:$PATH |
|||
export JAVA_HOME=/opt/openjdk/jdk-${JAVA_VERSION} |
|||
|
|||
STATUS_CODE=0 |
|||
while [[ "$STATUS_CODE" != "404" ]] ; do |
|||
echo "Will retry in 2 seconds" |
|||
sleep 2 |
|||
|
|||
STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$DUMMY_AUTHORITY") |
|||
|
|||
if [[ "$STATUS_CODE" = "200" ]]; then |
|||
echo "Setup should already be done. Will not run." |
|||
exit 0 |
|||
fi |
|||
done |
|||
|
|||
set -e |
|||
|
|||
kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli |
|||
|
|||
kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600" |
|||
kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i |
|||
|
|||
TEST_USER_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER" -s "firstName=$TEST_USER" -s "lastName=$TEST_USER" -s "email=$TEST_USER_MAIL" -s emailVerified=true -s enabled=true -i) |
|||
kcadm.sh update users/$TEST_USER_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER_PASSWORD" -n |
|||
|
|||
TEST_USER2_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER2" -s "firstName=$TEST_USER2" -s "lastName=$TEST_USER2" -s "email=$TEST_USER2_MAIL" -s emailVerified=true -s enabled=true -i) |
|||
kcadm.sh update users/$TEST_USER2_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER2_PASSWORD" -n |
|||
|
|||
TEST_USER3_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER3" -s "firstName=$TEST_USER3" -s "lastName=$TEST_USER3" -s "email=$TEST_USER3_MAIL" -s emailVerified=true -s enabled=true -i) |
|||
kcadm.sh update users/$TEST_USER3_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER3_PASSWORD" -n |
|||
|
|||
# Dummy realm to mark end of setup |
|||
kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLifespan=600" |
@ -0,0 +1,40 @@ |
|||
FROM docker.io/library/debian:bookworm-slim |
|||
|
|||
SHELL ["/bin/bash", "-o", "pipefail", "-c"] |
|||
|
|||
ENV DEBIAN_FRONTEND=noninteractive |
|||
|
|||
RUN apt-get update \ |
|||
&& apt-get install -y ca-certificates curl \ |
|||
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ |
|||
&& chmod a+r /etc/apt/keyrings/docker.asc \ |
|||
&& echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list \ |
|||
&& apt-get update \ |
|||
&& apt-get install -y --no-install-recommends \ |
|||
containerd.io \ |
|||
docker-buildx-plugin \ |
|||
docker-ce \ |
|||
docker-ce-cli \ |
|||
docker-compose-plugin \ |
|||
git \ |
|||
libmariadb-dev-compat \ |
|||
libpq5 \ |
|||
nodejs \ |
|||
npm \ |
|||
openssl \ |
|||
&& rm -rf /var/lib/apt/lists/* |
|||
|
|||
RUN mkdir /playwright |
|||
WORKDIR /playwright |
|||
|
|||
COPY package.json . |
|||
RUN npm install && npx playwright install-deps && npx playwright install firefox |
|||
|
|||
COPY docker-compose.yml test.env ./ |
|||
COPY compose ./compose |
|||
|
|||
COPY *.ts test.env ./ |
|||
COPY tests ./tests |
|||
|
|||
ENTRYPOINT ["/usr/bin/npx", "playwright"] |
|||
CMD ["test"] |
@ -0,0 +1,40 @@ |
|||
FROM playwright_oidc_vaultwarden_prebuilt AS prebuilt |
|||
|
|||
FROM node:18-bookworm AS build |
|||
|
|||
ARG REPO_URL |
|||
ARG COMMIT_HASH |
|||
|
|||
ENV REPO_URL=$REPO_URL |
|||
ENV COMMIT_HASH=$COMMIT_HASH |
|||
|
|||
COPY --from=prebuilt /web-vault /web-vault |
|||
|
|||
COPY build.sh /build.sh |
|||
RUN /build.sh |
|||
|
|||
######################## RUNTIME IMAGE ######################## |
|||
FROM docker.io/library/debian:bookworm-slim |
|||
|
|||
ENV DEBIAN_FRONTEND=noninteractive |
|||
|
|||
# Create data folder and Install needed libraries |
|||
RUN mkdir /data && \ |
|||
apt-get update && apt-get install -y \ |
|||
--no-install-recommends \ |
|||
ca-certificates \ |
|||
curl \ |
|||
libmariadb-dev-compat \ |
|||
libpq5 \ |
|||
openssl && \ |
|||
rm -rf /var/lib/apt/lists/* |
|||
|
|||
# Copies the files from the context (Rocket.toml file and web-vault) |
|||
# and the binary from the "build" stage to the current stage |
|||
WORKDIR / |
|||
|
|||
COPY --from=prebuilt /start.sh . |
|||
COPY --from=prebuilt /vaultwarden . |
|||
COPY --from=build /web-vault ./web-vault |
|||
|
|||
ENTRYPOINT ["/start.sh"] |
@ -0,0 +1,24 @@ |
|||
#!/bin/bash |
|||
|
|||
echo $REPO_URL |
|||
echo $COMMIT_HASH |
|||
|
|||
if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then |
|||
rm -rf /web-vault |
|||
|
|||
mkdir bw_web_builds; |
|||
cd bw_web_builds; |
|||
|
|||
git -c init.defaultBranch=main init |
|||
git remote add origin "$REPO_URL" |
|||
git fetch --depth 1 origin "$COMMIT_HASH" |
|||
git -c advice.detachedHead=false checkout FETCH_HEAD |
|||
|
|||
export VAULT_VERSION=$(cat Dockerfile | grep "ARG VAULT_VERSION" | cut -d "=" -f2) |
|||
./scripts/checkout_web_vault.sh |
|||
./scripts/patch_web_vault.sh |
|||
./scripts/build_web_vault.sh |
|||
printf '{"version":"%s"}' "$COMMIT_HASH" > ./web-vault/apps/web/build/vw-version.json |
|||
|
|||
mv ./web-vault/apps/web/build /web-vault |
|||
fi |
@ -0,0 +1,124 @@ |
|||
services: |
|||
VaultwardenPrebuild: |
|||
profiles: ["playwright", "vaultwarden"] |
|||
container_name: playwright_oidc_vaultwarden_prebuilt |
|||
image: playwright_oidc_vaultwarden_prebuilt |
|||
build: |
|||
context: .. |
|||
dockerfile: Dockerfile |
|||
entrypoint: /bin/bash |
|||
restart: "no" |
|||
|
|||
Vaultwarden: |
|||
profiles: ["playwright", "vaultwarden"] |
|||
container_name: playwright_oidc_vaultwarden-${ENV:-dev} |
|||
image: playwright_oidc_vaultwarden-${ENV:-dev} |
|||
network_mode: "host" |
|||
build: |
|||
context: compose/warden |
|||
dockerfile: Dockerfile |
|||
args: |
|||
REPO_URL: ${PW_WV_REPO_URL:-} |
|||
COMMIT_HASH: ${PW_WV_COMMIT_HASH:-} |
|||
env_file: ${DC_ENV_FILE:-.env} |
|||
environment: |
|||
- DATABASE_URL |
|||
- I_REALLY_WANT_VOLATILE_STORAGE |
|||
- LOG_LEVEL |
|||
- LOGIN_RATELIMIT_MAX_BURST |
|||
- SMTP_HOST |
|||
- SMTP_FROM |
|||
- SMTP_DEBUG |
|||
- SSO_DEBUG_TOKENS |
|||
- SSO_FRONTEND |
|||
- SSO_ENABLED |
|||
- SSO_ONLY |
|||
restart: "no" |
|||
depends_on: |
|||
- VaultwardenPrebuild |
|||
|
|||
Playwright: |
|||
profiles: ["playwright"] |
|||
container_name: playwright_oidc_playwright |
|||
image: playwright_oidc_playwright |
|||
network_mode: "host" |
|||
build: |
|||
context: . |
|||
dockerfile: compose/playwright/Dockerfile |
|||
environment: |
|||
- PW_WV_REPO_URL |
|||
- PW_WV_COMMIT_HASH |
|||
restart: "no" |
|||
volumes: |
|||
- /var/run/docker.sock:/var/run/docker.sock |
|||
- ..:/project |
|||
|
|||
Mariadb: |
|||
profiles: ["playwright"] |
|||
container_name: playwright_mariadb |
|||
image: mariadb:11.2.4 |
|||
env_file: test.env |
|||
healthcheck: |
|||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] |
|||
start_period: 10s |
|||
interval: 10s |
|||
ports: |
|||
- ${MARIADB_PORT}:3306 |
|||
|
|||
Mysql: |
|||
profiles: ["playwright"] |
|||
container_name: playwright_mysql |
|||
image: mysql:8.4.1 |
|||
env_file: test.env |
|||
healthcheck: |
|||
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] |
|||
start_period: 10s |
|||
interval: 10s |
|||
ports: |
|||
- ${MYSQL_PORT}:3306 |
|||
|
|||
Postgres: |
|||
profiles: ["playwright"] |
|||
container_name: playwright_postgres |
|||
image: postgres:16.3 |
|||
env_file: test.env |
|||
healthcheck: |
|||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] |
|||
start_period: 20s |
|||
interval: 30s |
|||
ports: |
|||
- ${POSTGRES_PORT}:5432 |
|||
|
|||
Maildev: |
|||
profiles: ["vaultwarden", "maildev"] |
|||
container_name: maildev |
|||
image: timshel/maildev:3.0.4 |
|||
ports: |
|||
- ${SMTP_PORT}:1025 |
|||
- 1080:1080 |
|||
|
|||
Keycloak: |
|||
profiles: ["keycloak", "vaultwarden"] |
|||
container_name: keycloak-${ENV:-dev} |
|||
image: quay.io/keycloak/keycloak:25.0.4 |
|||
network_mode: "host" |
|||
command: |
|||
- start-dev |
|||
env_file: ${DC_ENV_FILE:-.env} |
|||
|
|||
KeycloakSetup: |
|||
profiles: ["keycloak", "vaultwarden"] |
|||
container_name: keycloakSetup-${ENV:-dev} |
|||
image: keycloak_setup-${ENV:-dev} |
|||
build: |
|||
context: compose/keycloak |
|||
dockerfile: Dockerfile |
|||
args: |
|||
KEYCLOAK_VERSION: 25.0.4 |
|||
JAVA_URL: https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz |
|||
JAVA_VERSION: 21.0.2 |
|||
network_mode: "host" |
|||
depends_on: |
|||
- Keycloak |
|||
restart: "no" |
|||
env_file: ${DC_ENV_FILE:-.env} |
@ -0,0 +1,22 @@ |
|||
import { firefox, type FullConfig } from '@playwright/test'; |
|||
import { execSync } from 'node:child_process'; |
|||
import fs from 'fs'; |
|||
|
|||
const utils = require('./global-utils'); |
|||
|
|||
utils.loadEnv(); |
|||
|
|||
async function globalSetup(config: FullConfig) { |
|||
// Are we running in docker and the project is mounted ?
|
|||
const path = (fs.existsSync("/project/playwright/playwright.config.ts") ? "/project/playwright" : "."); |
|||
execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build VaultwardenPrebuild`, { |
|||
env: { ...process.env }, |
|||
stdio: "inherit" |
|||
}); |
|||
execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build Vaultwarden`, { |
|||
env: { ...process.env }, |
|||
stdio: "inherit" |
|||
}); |
|||
} |
|||
|
|||
export default globalSetup; |
@ -0,0 +1,246 @@ |
|||
import { expect, type Browser, type TestInfo } from '@playwright/test'; |
|||
import { EventEmitter } from "events"; |
|||
import { type Mail, MailServer } from 'maildev'; |
|||
import { execSync } from 'node:child_process'; |
|||
|
|||
import dotenv from 'dotenv'; |
|||
import dotenvExpand from 'dotenv-expand'; |
|||
|
|||
const fs = require("fs"); |
|||
const { spawn } = require('node:child_process'); |
|||
|
|||
export function loadEnv(){ |
|||
var myEnv = dotenv.config({ path: 'test.env' }); |
|||
dotenvExpand.expand(myEnv); |
|||
|
|||
return { |
|||
user1: { |
|||
email: process.env.TEST_USER_MAIL, |
|||
name: process.env.TEST_USER, |
|||
password: process.env.TEST_USER_PASSWORD, |
|||
}, |
|||
user2: { |
|||
email: process.env.TEST_USER2_MAIL, |
|||
name: process.env.TEST_USER2, |
|||
password: process.env.TEST_USER2_PASSWORD, |
|||
}, |
|||
user3: { |
|||
email: process.env.TEST_USER3_MAIL, |
|||
name: process.env.TEST_USER3, |
|||
password: process.env.TEST_USER3_PASSWORD, |
|||
}, |
|||
} |
|||
} |
|||
|
|||
export async function waitFor(url: String, browser: Browser) { |
|||
var ready = false; |
|||
var context; |
|||
|
|||
do { |
|||
try { |
|||
context = await browser.newContext(); |
|||
const page = await context.newPage(); |
|||
await page.waitForTimeout(500); |
|||
const result = await page.goto(url); |
|||
ready = result.status() === 200; |
|||
} catch(e) { |
|||
if( !e.message.includes("CONNECTION_REFUSED") ){ |
|||
throw e; |
|||
} |
|||
} finally { |
|||
await context.close(); |
|||
} |
|||
} while(!ready); |
|||
} |
|||
|
|||
export function startComposeService(serviceName: String){ |
|||
console.log(`Starting ${serviceName}`); |
|||
execSync(`docker compose --profile playwright --env-file test.env up -d ${serviceName}`); |
|||
} |
|||
|
|||
export function stopComposeService(serviceName: String){ |
|||
console.log(`Stopping ${serviceName}`); |
|||
execSync(`docker compose --profile playwright --env-file test.env stop ${serviceName}`); |
|||
} |
|||
|
|||
function wipeSqlite(){ |
|||
console.log(`Delete Vaultwarden container to wipe sqlite`); |
|||
execSync(`docker compose --env-file test.env stop Vaultwarden`); |
|||
execSync(`docker compose --env-file test.env rm -f Vaultwarden`); |
|||
} |
|||
|
|||
async function wipeMariaDB(){ |
|||
var mysql = require('mysql2/promise'); |
|||
var ready = false; |
|||
var connection; |
|||
|
|||
do { |
|||
try { |
|||
connection = await mysql.createConnection({ |
|||
user: process.env.MARIADB_USER, |
|||
host: "127.0.0.1", |
|||
database: process.env.MARIADB_DATABASE, |
|||
password: process.env.MARIADB_PASSWORD, |
|||
port: process.env.MARIADB_PORT, |
|||
}); |
|||
|
|||
await connection.execute(`DROP DATABASE ${process.env.MARIADB_DATABASE}`); |
|||
await connection.execute(`CREATE DATABASE ${process.env.MARIADB_DATABASE}`); |
|||
console.log('Successfully wiped mariadb'); |
|||
ready = true; |
|||
} catch (err) { |
|||
console.log(`Error when wiping mariadb: ${err}`); |
|||
} finally { |
|||
if( connection ){ |
|||
connection.end(); |
|||
} |
|||
} |
|||
await new Promise(r => setTimeout(r, 1000)); |
|||
} while(!ready); |
|||
} |
|||
|
|||
async function wipeMysqlDB(){ |
|||
var mysql = require('mysql2/promise'); |
|||
var ready = false; |
|||
var connection; |
|||
|
|||
do{ |
|||
try { |
|||
connection = await mysql.createConnection({ |
|||
user: process.env.MYSQL_USER, |
|||
host: "127.0.0.1", |
|||
database: process.env.MYSQL_DATABASE, |
|||
password: process.env.MYSQL_PASSWORD, |
|||
port: process.env.MYSQL_PORT, |
|||
}); |
|||
|
|||
await connection.execute(`DROP DATABASE ${process.env.MYSQL_DATABASE}`); |
|||
await connection.execute(`CREATE DATABASE ${process.env.MYSQL_DATABASE}`); |
|||
console.log('Successfully wiped mysql'); |
|||
ready = true; |
|||
} catch (err) { |
|||
console.log(`Error when wiping mysql: ${err}`); |
|||
} finally { |
|||
if( connection ){ |
|||
connection.end(); |
|||
} |
|||
} |
|||
await new Promise(r => setTimeout(r, 1000)); |
|||
} while(!ready); |
|||
} |
|||
|
|||
async function wipePostgres(){ |
|||
const { Client } = require('pg'); |
|||
|
|||
const client = new Client({ |
|||
user: process.env.POSTGRES_USER, |
|||
host: "127.0.0.1", |
|||
database: "postgres", |
|||
password: process.env.POSTGRES_PASSWORD, |
|||
port: process.env.POSTGRES_PORT, |
|||
}); |
|||
|
|||
try { |
|||
await client.connect(); |
|||
await client.query(`DROP DATABASE ${process.env.POSTGRES_DB}`); |
|||
await client.query(`CREATE DATABASE ${process.env.POSTGRES_DB}`); |
|||
console.log('Successfully wiped postgres'); |
|||
} catch (err) { |
|||
console.log(`Error when wiping postgres: ${err}`); |
|||
} finally { |
|||
client.end(); |
|||
} |
|||
} |
|||
|
|||
function dbConfig(testInfo: TestInfo){ |
|||
switch(testInfo.project.name) { |
|||
case "postgres": |
|||
case "sso-postgres": |
|||
return { DATABASE_URL: `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@127.0.0.1:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}` }; |
|||
case "mariadb": |
|||
case "sso-mariadb": |
|||
return { DATABASE_URL: `mysql://${process.env.MARIADB_USER}:${process.env.MARIADB_PASSWORD}@127.0.0.1:${process.env.MARIADB_PORT}/${process.env.MARIADB_DATABASE}` }; |
|||
case "mysql": |
|||
case "sso-mysql": |
|||
return { DATABASE_URL: `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PASSWORD}@127.0.0.1:${process.env.MYSQL_PORT}/${process.env.MYSQL_DATABASE}`}; |
|||
case "sqlite": |
|||
case "sso-sqlite": |
|||
return { I_REALLY_WANT_VOLATILE_STORAGE: true }; |
|||
default: |
|||
throw new Error(`Unknow database name: ${testInfo.project.name}`); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* All parameters passed in `env` need to be added to the docker-compose.yml |
|||
**/ |
|||
export async function startVault(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) { |
|||
if( resetDB ){ |
|||
switch(testInfo.project.name) { |
|||
case "postgres": |
|||
case "sso-postgres": |
|||
await wipePostgres(); |
|||
break; |
|||
case "mariadb": |
|||
case "sso-mariadb": |
|||
await wipeMariaDB(); |
|||
break; |
|||
case "mysql": |
|||
case "sso-mysql": |
|||
await wipeMysqlDB(); |
|||
break; |
|||
case "sqlite": |
|||
case "sso-sqlite": |
|||
wipeSqlite(); |
|||
break; |
|||
default: |
|||
throw new Error(`Unknow database name: ${testInfo.project.name}`); |
|||
} |
|||
} |
|||
|
|||
console.log(`Starting Vaultwarden`); |
|||
execSync(`docker compose --profile playwright --env-file test.env up -d Vaultwarden`, { |
|||
env: { ...env, ...dbConfig(testInfo) }, |
|||
}); |
|||
await waitFor("/", browser); |
|||
console.log(`Vaultwarden running on: ${process.env.DOMAIN}`); |
|||
} |
|||
|
|||
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 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) { |
|||
await expect(page.locator('bit-toast').filter({ hasText })).toBeVisible(); |
|||
await page.locator('bit-toast').filter({ hasText }).getByRole('button').click(); |
|||
await expect(page.locator('bit-toast').filter({ hasText })).toHaveCount(0); |
|||
} |
|||
|
|||
export async function cleanLanding(page: Page) { |
|||
await page.goto('/', { waitUntil: 'domcontentloaded' }); |
|||
await expect(page.getByRole('button').nth(0)).toBeVisible(); |
|||
|
|||
const logged = await page.getByRole('button', { name: 'Log out' }).count(); |
|||
if( logged > 0 ){ |
|||
await page.getByRole('button', { name: 'Log out' }).click(); |
|||
await page.getByRole('button', { name: 'Log out' }).click(); |
|||
} |
|||
} |
|||
|
|||
export async function logout(test: Test, page: Page, user: { name: string }) { |
|||
await test.step('logout', async () => { |
|||
await page.getByRole('button', { name: user.name, exact: true }).click(); |
|||
await page.getByRole('menuitem', { name: 'Log out' }).click(); |
|||
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible(); |
|||
}); |
|||
} |
File diff suppressed because it is too large
@ -0,0 +1,21 @@ |
|||
{ |
|||
"name": "scenarios", |
|||
"version": "1.0.0", |
|||
"description": "", |
|||
"main": "index.js", |
|||
"scripts": {}, |
|||
"keywords": [], |
|||
"author": "", |
|||
"license": "ISC", |
|||
"devDependencies": { |
|||
"@playwright/test": "^1.53.0", |
|||
"dotenv": "^16.5.0", |
|||
"dotenv-expand": "^12.0.2", |
|||
"maildev": "npm:@timshel_npm/maildev@^3.1.2" |
|||
}, |
|||
"dependencies": { |
|||
"mysql2": "^3.14.1", |
|||
"otpauth": "^9.4.0", |
|||
"pg": "^8.16.0" |
|||
} |
|||
} |
@ -0,0 +1,143 @@ |
|||
import { defineConfig, devices } from '@playwright/test'; |
|||
import { exec } from 'node:child_process'; |
|||
|
|||
const utils = require('./global-utils'); |
|||
|
|||
utils.loadEnv(); |
|||
|
|||
/** |
|||
* See https://playwright.dev/docs/test-configuration.
|
|||
*/ |
|||
export default defineConfig({ |
|||
testDir: './.', |
|||
/* Run tests in files in parallel */ |
|||
fullyParallel: false, |
|||
|
|||
/* Fail the build on CI if you accidentally left test.only in the source code. */ |
|||
forbidOnly: !!process.env.CI, |
|||
|
|||
retries: 0, |
|||
workers: 1, |
|||
|
|||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ |
|||
reporter: 'html', |
|||
|
|||
/* Long global timeout for complex tests |
|||
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough). |
|||
*/ |
|||
timeout: 120 * 1000, |
|||
actionTimeout: 10 * 1000, |
|||
navigationTimeout: 10 * 1000, |
|||
expect: { timeout: 10 * 1000 }, |
|||
|
|||
/* 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.DOMAIN, |
|||
browserName: 'firefox', |
|||
locale: 'en-GB', |
|||
timezoneId: 'Europe/London', |
|||
|
|||
/* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */ |
|||
trace: 'on', |
|||
viewport: { |
|||
width: 1080, |
|||
height: 720, |
|||
}, |
|||
video: "on", |
|||
}, |
|||
|
|||
/* Configure projects for major browsers */ |
|||
projects: [ |
|||
{ |
|||
name: 'mariadb-setup', |
|||
testMatch: 'tests/setups/db-setup.ts', |
|||
use: { serviceName: "Mariadb" }, |
|||
teardown: 'mariadb-teardown', |
|||
}, |
|||
{ |
|||
name: 'mysql-setup', |
|||
testMatch: 'tests/setups/db-setup.ts', |
|||
use: { serviceName: "Mysql" }, |
|||
teardown: 'mysql-teardown', |
|||
}, |
|||
{ |
|||
name: 'postgres-setup', |
|||
testMatch: 'tests/setups/db-setup.ts', |
|||
use: { serviceName: "Postgres" }, |
|||
teardown: 'postgres-teardown', |
|||
}, |
|||
{ |
|||
name: 'sso-setup', |
|||
testMatch: 'tests/setups/sso-setup.ts', |
|||
teardown: 'sso-teardown', |
|||
}, |
|||
|
|||
{ |
|||
name: 'mariadb', |
|||
testMatch: 'tests/*.spec.ts', |
|||
testIgnore: 'tests/sso_*.spec.ts', |
|||
dependencies: ['mariadb-setup'], |
|||
}, |
|||
{ |
|||
name: 'mysql', |
|||
testMatch: 'tests/*.spec.ts', |
|||
testIgnore: 'tests/sso_*.spec.ts', |
|||
dependencies: ['mysql-setup'], |
|||
}, |
|||
{ |
|||
name: 'postgres', |
|||
testMatch: 'tests/*.spec.ts', |
|||
testIgnore: 'tests/sso_*.spec.ts', |
|||
dependencies: ['postgres-setup'], |
|||
}, |
|||
{ |
|||
name: 'sqlite', |
|||
testMatch: 'tests/*.spec.ts', |
|||
testIgnore: 'tests/sso_*.spec.ts', |
|||
}, |
|||
|
|||
{ |
|||
name: 'sso-mariadb', |
|||
testMatch: 'tests/sso_*.spec.ts', |
|||
dependencies: ['sso-setup', 'mariadb-setup'], |
|||
}, |
|||
{ |
|||
name: 'sso-mysql', |
|||
testMatch: 'tests/sso_*.spec.ts', |
|||
dependencies: ['sso-setup', 'mysql-setup'], |
|||
}, |
|||
{ |
|||
name: 'sso-postgres', |
|||
testMatch: 'tests/sso_*.spec.ts', |
|||
dependencies: ['sso-setup', 'postgres-setup'], |
|||
}, |
|||
{ |
|||
name: 'sso-sqlite', |
|||
testMatch: 'tests/sso_*.spec.ts', |
|||
dependencies: ['sso-setup'], |
|||
}, |
|||
|
|||
{ |
|||
name: 'mariadb-teardown', |
|||
testMatch: 'tests/setups/db-teardown.ts', |
|||
use: { serviceName: "Mariadb" }, |
|||
}, |
|||
{ |
|||
name: 'mysql-teardown', |
|||
testMatch: 'tests/setups/db-teardown.ts', |
|||
use: { serviceName: "Mysql" }, |
|||
}, |
|||
{ |
|||
name: 'postgres-teardown', |
|||
testMatch: 'tests/setups/db-teardown.ts', |
|||
use: { serviceName: "Postgres" }, |
|||
}, |
|||
{ |
|||
name: 'sso-teardown', |
|||
testMatch: 'tests/setups/sso-teardown.ts', |
|||
}, |
|||
], |
|||
|
|||
globalSetup: require.resolve('./global-setup'), |
|||
}); |
@ -0,0 +1,93 @@ |
|||
################################################################## |
|||
### Shared Playwright conf test file Vaultwarden and Databases ### |
|||
################################################################## |
|||
|
|||
ENV=test |
|||
DC_ENV_FILE=test.env |
|||
COMPOSE_IGNORE_ORPHANS=True |
|||
DOCKER_BUILDKIT=1 |
|||
|
|||
##################### |
|||
# Playwright Config # |
|||
##################### |
|||
PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false} |
|||
PW_SMTP_FROM=vaultwarden@playwright.test |
|||
|
|||
##################### |
|||
# Maildev Config # |
|||
##################### |
|||
MAILDEV_HTTP_PORT=1081 |
|||
MAILDEV_SMTP_PORT=1026 |
|||
MAILDEV_HOST=127.0.0.1 |
|||
|
|||
################ |
|||
# Users Config # |
|||
################ |
|||
TEST_USER=test |
|||
TEST_USER_PASSWORD=Master Password |
|||
TEST_USER_MAIL=${TEST_USER}@example.com |
|||
|
|||
TEST_USER2=test2 |
|||
TEST_USER2_PASSWORD=Master Password |
|||
TEST_USER2_MAIL=${TEST_USER2}@example.com |
|||
|
|||
TEST_USER3=test3 |
|||
TEST_USER3_PASSWORD=Master Password |
|||
TEST_USER3_MAIL=${TEST_USER3}@example.com |
|||
|
|||
################### |
|||
# Keycloak Config # |
|||
################### |
|||
KEYCLOAK_ADMIN=admin |
|||
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} |
|||
KC_HTTP_HOST=127.0.0.1 |
|||
KC_HTTP_PORT=8081 |
|||
|
|||
# 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} |
|||
|
|||
###################### |
|||
# Vaultwarden Config # |
|||
###################### |
|||
ROCKET_PORT=8003 |
|||
DOMAIN=http://127.0.0.1:${ROCKET_PORT} |
|||
LOG_LEVEL=info,oidcwarden::sso=debug |
|||
LOGIN_RATELIMIT_MAX_BURST=100 |
|||
|
|||
SMTP_SECURITY=off |
|||
SMTP_PORT=${MAILDEV_SMTP_PORT} |
|||
SMTP_FROM_NAME=Vaultwarden |
|||
SMTP_TIMEOUT=5 |
|||
|
|||
SSO_CLIENT_ID=warden |
|||
SSO_CLIENT_SECRET=warden |
|||
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} |
|||
SSO_DEBUG_TOKENS=true |
|||
|
|||
########################### |
|||
# Docker MariaDb container# |
|||
########################### |
|||
MARIADB_PORT=3307 |
|||
MARIADB_ROOT_PASSWORD=warden |
|||
MARIADB_USER=warden |
|||
MARIADB_PASSWORD=warden |
|||
MARIADB_DATABASE=warden |
|||
|
|||
########################### |
|||
# Docker Mysql container# |
|||
########################### |
|||
MYSQL_PORT=3309 |
|||
MYSQL_ROOT_PASSWORD=warden |
|||
MYSQL_USER=warden |
|||
MYSQL_PASSWORD=warden |
|||
MYSQL_DATABASE=warden |
|||
|
|||
############################ |
|||
# Docker Postgres container# |
|||
############################ |
|||
POSTGRES_PORT=5433 |
|||
POSTGRES_USER=warden |
|||
POSTGRES_PASSWORD=warden |
|||
POSTGRES_DB=warden |
@ -0,0 +1,37 @@ |
|||
import { test, expect, type TestInfo } from '@playwright/test'; |
|||
|
|||
import * as utils from "../global-utils"; |
|||
import { createAccount } from './setups/user'; |
|||
|
|||
let users = utils.loadEnv(); |
|||
|
|||
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
|||
await utils.startVault(browser, testInfo); |
|||
}); |
|||
|
|||
test.afterAll('Teardown', async ({}) => { |
|||
utils.stopVault(); |
|||
}); |
|||
|
|||
test('Create', async ({ page }) => { |
|||
await createAccount(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 utils.checkNotification(page, 'Organisation created'); |
|||
}); |
|||
|
|||
await test.step('Create Collection', async () => { |
|||
await page.getByRole('link', { name: 'Collections' }).click(); |
|||
await page.getByRole('button', { name: 'New' }).click(); |
|||
await page.getByRole('menuitem', { name: 'Collection' }).click(); |
|||
await page.getByLabel('Name (required)').fill('RandomCollec'); |
|||
await page.getByRole('button', { name: 'Save' }).click(); |
|||
await utils.checkNotification(page, 'Created collection RandomCollec'); |
|||
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible(); |
|||
}); |
|||
}); |
@ -0,0 +1,100 @@ |
|||
import { test, expect, type TestInfo } from '@playwright/test'; |
|||
import { MailDev } from 'maildev'; |
|||
|
|||
const utils = require('../global-utils'); |
|||
import { createAccount, logUser } from './setups/user'; |
|||
import { activateEmail, retrieveEmailCode, disableEmail } from './setups/2fa'; |
|||
|
|||
let users = utils.loadEnv(); |
|||
|
|||
let mailserver; |
|||
|
|||
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
|||
mailserver = new MailDev({ |
|||
port: process.env.MAILDEV_SMTP_PORT, |
|||
web: { port: process.env.MAILDEV_HTTP_PORT }, |
|||
}) |
|||
|
|||
await mailserver.listen(); |
|||
|
|||
await utils.startVault(browser, testInfo, { |
|||
SMTP_HOST: process.env.MAILDEV_HOST, |
|||
SMTP_FROM: process.env.PW_SMTP_FROM, |
|||
}); |
|||
}); |
|||
|
|||
test.afterAll('Teardown', async ({}) => { |
|||
utils.stopVault(); |
|||
if( mailserver ){ |
|||
await mailserver.close(); |
|||
} |
|||
}); |
|||
|
|||
test('Account creation', async ({ page }) => { |
|||
const mailBuffer = mailserver.buffer(users.user1.email); |
|||
|
|||
await createAccount(test, page, users.user1, mailBuffer); |
|||
|
|||
mailBuffer.close(); |
|||
}); |
|||
|
|||
test('Login', async ({ context, page }) => { |
|||
const mailBuffer = mailserver.buffer(users.user1.email); |
|||
|
|||
await logUser(test, page, users.user1, mailBuffer); |
|||
|
|||
await test.step('verify email', async () => { |
|||
await page.getByText('Verify your account\'s email').click(); |
|||
await expect(page.getByText('Verify your account\'s email')).toBeVisible(); |
|||
await page.getByRole('button', { name: 'Send email' }).click(); |
|||
|
|||
await utils.checkNotification(page, 'Check your email inbox for a verification link'); |
|||
|
|||
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email"); |
|||
expect(verify.from[0]?.address).toBe(process.env.PW_SMTP_FROM); |
|||
|
|||
const page2 = await context.newPage(); |
|||
await page2.setContent(verify.html); |
|||
const link = await page2.getByTestId("verify").getAttribute("href"); |
|||
await page2.close(); |
|||
|
|||
await page.goto(link); |
|||
await utils.checkNotification(page, 'Account email verified'); |
|||
}); |
|||
|
|||
mailBuffer.close(); |
|||
}); |
|||
|
|||
test('Activate 2fa', async ({ page }) => { |
|||
const emails = mailserver.buffer(users.user1.email); |
|||
|
|||
await logUser(test, page, users.user1); |
|||
|
|||
await activateEmail(test, page, users.user1, emails); |
|||
|
|||
emails.close(); |
|||
}); |
|||
|
|||
test('2fa', async ({ page }) => { |
|||
const emails = mailserver.buffer(users.user1.email); |
|||
|
|||
await test.step('login', async () => { |
|||
await page.goto('/'); |
|||
|
|||
await page.getByLabel(/Email address/).fill(users.user1.email); |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
await page.getByLabel('Master password').fill(users.user1.password); |
|||
await page.getByRole('button', { name: 'Log in with master password' }).click(); |
|||
|
|||
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(/Vaults/); |
|||
}) |
|||
|
|||
await disableEmail(test, page, users.user1); |
|||
|
|||
emails.close(); |
|||
}); |
@ -0,0 +1,51 @@ |
|||
import { test, expect, type Page, type TestInfo } from '@playwright/test'; |
|||
import * as OTPAuth from "otpauth"; |
|||
|
|||
import * as utils from "../global-utils"; |
|||
import { createAccount, logUser } from './setups/user'; |
|||
import { activateTOTP, disableTOTP } from './setups/2fa'; |
|||
|
|||
let users = utils.loadEnv(); |
|||
let totp; |
|||
|
|||
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
|||
await utils.startVault(browser, testInfo, {}); |
|||
}); |
|||
|
|||
test.afterAll('Teardown', async ({}) => { |
|||
utils.stopVault(); |
|||
}); |
|||
|
|||
test('Account creation', async ({ page }) => { |
|||
await createAccount(test, page, users.user1); |
|||
}); |
|||
|
|||
test('Master password login', async ({ page }) => { |
|||
await logUser(test, page, users.user1); |
|||
}); |
|||
|
|||
test('Authenticator 2fa', async ({ page }) => { |
|||
await logUser(test, page, users.user1); |
|||
|
|||
let totp = await activateTOTP(test, page, users.user1); |
|||
|
|||
await utils.logout(test, page, users.user1); |
|||
|
|||
await test.step('login', async () => { |
|||
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); |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
await page.getByLabel('Master password').fill(users.user1.password); |
|||
await page.getByRole('button', { name: 'Log in with master password' }).click(); |
|||
|
|||
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/); |
|||
}); |
|||
|
|||
await disableTOTP(test, page, users.user1); |
|||
}); |
@ -0,0 +1,115 @@ |
|||
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 { createAccount, logUser } from './setups/user'; |
|||
|
|||
let users = utils.loadEnv(); |
|||
|
|||
let mailServer, mail1Buffer, mail2Buffer, mail3Buffer; |
|||
|
|||
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
|||
mailServer = new MailDev({ |
|||
port: process.env.MAILDEV_SMTP_PORT, |
|||
web: { port: process.env.MAILDEV_HTTP_PORT }, |
|||
}) |
|||
|
|||
await mailServer.listen(); |
|||
|
|||
await utils.startVault(browser, testInfo, { |
|||
SMTP_HOST: process.env.MAILDEV_HOST, |
|||
SMTP_FROM: process.env.PW_SMTP_FROM, |
|||
}); |
|||
|
|||
mail1Buffer = mailServer.buffer(users.user1.email); |
|||
mail2Buffer = mailServer.buffer(users.user2.email); |
|||
mail3Buffer = mailServer.buffer(users.user3.email); |
|||
}); |
|||
|
|||
test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { |
|||
utils.stopVault(testInfo); |
|||
[mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); |
|||
}); |
|||
|
|||
test('Create user3', async ({ page }) => { |
|||
await createAccount(test, page, users.user3, mail3Buffer); |
|||
}); |
|||
|
|||
test('Invite users', async ({ page }) => { |
|||
await createAccount(test, page, users.user1, mail1Buffer); |
|||
|
|||
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, { |
|||
navigate: false, |
|||
}); |
|||
}); |
|||
|
|||
test('invited with new account', async ({ page }) => { |
|||
const invited = await mail2Buffer.expect((mail) => mail.subject === 'Join Test'); |
|||
|
|||
await test.step('Create account', async () => { |
|||
await page.setContent(invited.html); |
|||
const link = await page.getByTestId('invite').getAttribute('href'); |
|||
await page.goto(link); |
|||
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); |
|||
|
|||
//await page.getByLabel('Name').fill(users.user2.name);
|
|||
await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password); |
|||
await page.getByLabel('Confirm new master password (').fill(users.user2.password); |
|||
await page.getByRole('button', { name: 'Create account' }).click(); |
|||
await utils.checkNotification(page, 'Your new account has been created'); |
|||
|
|||
// Redirected to the vault
|
|||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); |
|||
await utils.checkNotification(page, 'You have been logged in!'); |
|||
await utils.checkNotification(page, 'Invitation accepted'); |
|||
}); |
|||
|
|||
await test.step('Check mails', async () => { |
|||
await mail2Buffer.expect((m) => m.subject === 'Welcome'); |
|||
await mail2Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); |
|||
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); |
|||
}); |
|||
}); |
|||
|
|||
test('invited with existing account', async ({ page }) => { |
|||
const invited = await mail3Buffer.expect((mail) => mail.subject === 'Join Test'); |
|||
|
|||
await page.setContent(invited.html); |
|||
const link = await page.getByTestId('invite').getAttribute('href'); |
|||
|
|||
await page.goto(link); |
|||
|
|||
// We should be on login page with email prefilled
|
|||
await expect(page).toHaveTitle(/Vaultwarden Web/); |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
|
|||
// Unlock page
|
|||
await page.getByLabel('Master password').fill(users.user3.password); |
|||
await page.getByRole('button', { name: 'Log in with master password' }).click(); |
|||
|
|||
// We are now in the default vault page
|
|||
await expect(page).toHaveTitle(/Vaultwarden Web/); |
|||
await utils.checkNotification(page, 'Invitation accepted'); |
|||
|
|||
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); |
|||
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); |
|||
}); |
|||
|
|||
test('Confirm invited user', async ({ page }) => { |
|||
await logUser(test, page, users.user1, mail1Buffer); |
|||
|
|||
await orgs.members(test, page, 'Test'); |
|||
await orgs.confirm(test, page, 'Test', users.user2.email); |
|||
|
|||
await mail2Buffer.expect((m) => m.subject.includes('Invitation to Test confirmed')); |
|||
}); |
|||
|
|||
test('Organization is visible', async ({ page }) => { |
|||
await logUser(test, page, users.user2, mail2Buffer); |
|||
await page.getByRole('button', { name: 'vault: Test', exact: true }).click(); |
|||
await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); |
|||
}); |
@ -0,0 +1,54 @@ |
|||
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 { createAccount, logUser } from './setups/user'; |
|||
|
|||
let users = utils.loadEnv(); |
|||
|
|||
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
|||
await utils.startVault(browser, testInfo); |
|||
}); |
|||
|
|||
test.afterAll('Teardown', async ({}) => { |
|||
utils.stopVault(); |
|||
}); |
|||
|
|||
test('Invite', async ({ page }) => { |
|||
await createAccount(test, page, users.user3); |
|||
await createAccount(test, page, users.user1); |
|||
|
|||
await orgs.create(test, page, 'New organisation'); |
|||
await orgs.members(test, page, 'New organisation'); |
|||
|
|||
await test.step('missing user2', async () => { |
|||
await orgs.invite(test, page, 'New organisation', users.user2.email); |
|||
await expect(page.getByRole('row', { name: users.user2.email })).toHaveText(/Invited/); |
|||
}); |
|||
|
|||
await test.step('existing user3', async () => { |
|||
await orgs.invite(test, page, 'New organisation', users.user3.email); |
|||
await expect(page.getByRole('row', { name: users.user3.email })).toHaveText(/Needs confirmation/); |
|||
await orgs.confirm(test, page, 'New organisation', users.user3.email); |
|||
}); |
|||
|
|||
await test.step('confirm user2', async () => { |
|||
await createAccount(test, page, users.user2); |
|||
await logUser(test, page, users.user1); |
|||
await orgs.members(test, page, 'New organisation'); |
|||
await orgs.confirm(test, page, 'New organisation', users.user2.email); |
|||
}); |
|||
|
|||
await test.step('Org visible user2 ', async () => { |
|||
await logUser(test, page, users.user2); |
|||
await page.getByRole('button', { name: 'vault: New organisation', exact: true }).click(); |
|||
await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); |
|||
}); |
|||
|
|||
await test.step('Org visible user3 ', async () => { |
|||
await logUser(test, page, users.user3); |
|||
await page.getByRole('button', { name: 'vault: New organisation', exact: true }).click(); |
|||
await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); |
|||
}); |
|||
}); |
@ -0,0 +1,92 @@ |
|||
import { expect, type Page, Test } from '@playwright/test'; |
|||
import { type MailBuffer } from 'maildev'; |
|||
import * as OTPAuth from "otpauth"; |
|||
|
|||
import * as utils from '../../global-utils'; |
|||
|
|||
export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP { |
|||
return await test.step('Activate TOTP 2FA', async () => { |
|||
await page.getByRole('button', { name: user.name }).click(); |
|||
await page.getByRole('menuitem', { name: 'Account settings' }).click(); |
|||
await page.getByRole('link', { name: 'Security' }).click(); |
|||
await page.getByRole('link', { name: 'Two-step login' }).click(); |
|||
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); |
|||
await page.getByLabel('Master password (required)').fill(user.password); |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
|
|||
const secret = await page.getByLabel('Key').innerText(); |
|||
let totp = new OTPAuth.TOTP({ secret, period: 30 }); |
|||
|
|||
await page.getByLabel(/Verification code/).fill(totp.generate()); |
|||
await page.getByRole('button', { name: 'Turn on' }).click(); |
|||
await page.getByRole('heading', { name: 'Turned on', exact: true }); |
|||
await page.getByLabel('Close').click(); |
|||
|
|||
return totp; |
|||
}) |
|||
} |
|||
|
|||
export async function disableTOTP(test: Test, page: Page, user: { password: string }) { |
|||
await test.step('Disable TOTP 2FA', async () => { |
|||
await page.getByRole('button', { name: 'Test' }).click(); |
|||
await page.getByRole('menuitem', { name: 'Account settings' }).click(); |
|||
await page.getByRole('link', { name: 'Security' }).click(); |
|||
await page.getByRole('link', { name: 'Two-step login' }).click(); |
|||
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); |
|||
await page.getByLabel('Master password (required)').click(); |
|||
await page.getByLabel('Master password (required)').fill(user.password); |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
await page.getByRole('button', { name: 'Turn off' }).click(); |
|||
await page.getByRole('button', { name: 'Yes' }).click(); |
|||
await utils.checkNotification(page, 'Two-step login provider turned off'); |
|||
}); |
|||
} |
|||
|
|||
export async function activateEmail(test: Test, page: Page, user: { name: string, password: string }, mailBuffer: MailBuffer) { |
|||
await test.step('Activate Email 2FA', async () => { |
|||
await page.getByRole('button', { name: user.name }).click(); |
|||
await page.getByRole('menuitem', { name: 'Account settings' }).click(); |
|||
await page.getByRole('link', { name: 'Security' }).click(); |
|||
await page.getByRole('link', { name: 'Two-step login' }).click(); |
|||
await page.locator('bit-item').filter({ hasText: 'Email Email Enter a code sent' }).getByRole('button').click(); |
|||
await page.getByLabel('Master password (required)').fill(user.password); |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
await page.getByRole('button', { name: 'Send email' }).click(); |
|||
}); |
|||
|
|||
let code = await retrieveEmailCode(test, page, mailBuffer); |
|||
|
|||
await test.step('input code', async () => { |
|||
await page.getByLabel('2. Enter the resulting 6').fill(code); |
|||
await page.getByRole('button', { name: 'Turn on' }).click(); |
|||
await page.getByRole('heading', { name: 'Turned on', exact: true }); |
|||
}); |
|||
} |
|||
|
|||
export async function retrieveEmailCode(test: Test, page: Page, mailBuffer: MailBuffer): string { |
|||
return await test.step('retrieve code', async () => { |
|||
const codeMail = await mailBuffer.expect((mail) => mail.subject.includes("Login Verification Code")); |
|||
const page2 = await page.context().newPage(); |
|||
await page2.setContent(codeMail.html); |
|||
const code = await page2.getByTestId("2fa").innerText(); |
|||
await page2.close(); |
|||
return code; |
|||
}); |
|||
} |
|||
|
|||
export async function disableEmail(test: Test, page: Page, user: { password: string }) { |
|||
await test.step('Disable Email 2FA', async () => { |
|||
await page.getByRole('button', { name: 'Test' }).click(); |
|||
await page.getByRole('menuitem', { name: 'Account settings' }).click(); |
|||
await page.getByRole('link', { name: 'Security' }).click(); |
|||
await page.getByRole('link', { name: 'Two-step login' }).click(); |
|||
await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click(); |
|||
await page.getByLabel('Master password (required)').click(); |
|||
await page.getByLabel('Master password (required)').fill(user.password); |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
await page.getByRole('button', { name: 'Turn off' }).click(); |
|||
await page.getByRole('button', { name: 'Yes' }).click(); |
|||
|
|||
await utils.checkNotification(page, 'Two-step login provider turned off'); |
|||
}); |
|||
} |
@ -0,0 +1,7 @@ |
|||
import { test } from './db-test'; |
|||
|
|||
const utils = require('../../global-utils'); |
|||
|
|||
test('DB start', async ({ serviceName }) => { |
|||
utils.startComposeService(serviceName); |
|||
}); |
@ -0,0 +1,11 @@ |
|||
import { test } from './db-test'; |
|||
|
|||
const utils = require('../../global-utils'); |
|||
|
|||
utils.loadEnv(); |
|||
|
|||
test('DB teardown ?', async ({ serviceName }) => { |
|||
if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) { |
|||
utils.stopComposeService(serviceName); |
|||
} |
|||
}); |
@ -0,0 +1,9 @@ |
|||
import { test as base } from '@playwright/test'; |
|||
|
|||
export type TestOptions = { |
|||
serviceName: string; |
|||
}; |
|||
|
|||
export const test = base.extend<TestOptions>({ |
|||
serviceName: ['', { option: true }], |
|||
}); |
@ -0,0 +1,77 @@ |
|||
import { expect, type Browser,Page } from '@playwright/test'; |
|||
|
|||
import * as utils from '../../global-utils'; |
|||
|
|||
export async function create(test, page: Page, name: string) { |
|||
await test.step('Create Org', async () => { |
|||
await page.locator('a').filter({ hasText: 'Password Manager' }).first().click(); |
|||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); |
|||
await page.getByRole('link', { name: 'New organisation' }).click(); |
|||
await page.getByLabel('Organisation name (required)').fill(name); |
|||
await page.getByRole('button', { name: 'Submit' }).click(); |
|||
|
|||
await utils.checkNotification(page, 'Organisation created'); |
|||
}); |
|||
} |
|||
|
|||
export async function policies(test, page: Page, name: string) { |
|||
await test.step(`Navigate to ${name} policies`, async () => { |
|||
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); |
|||
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); |
|||
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); |
|||
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); |
|||
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click(); |
|||
await page.getByRole('link', { name: 'Policies' }).click(); |
|||
await expect(page.getByRole('heading', { name: 'Policies' })).toBeVisible(); |
|||
}); |
|||
} |
|||
|
|||
export async function members(test, page: Page, name: string) { |
|||
await test.step(`Navigate to ${name} members`, async () => { |
|||
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); |
|||
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); |
|||
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); |
|||
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); |
|||
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); |
|||
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); |
|||
await expect(page.getByRole('cell', { name: 'All' })).toBeVisible(); |
|||
}); |
|||
} |
|||
|
|||
export async function invite(test, page: Page, name: string, email: string) { |
|||
await test.step(`Invite ${email}`, async () => { |
|||
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); |
|||
await page.getByRole('button', { name: 'Invite member' }).click(); |
|||
await page.getByLabel('Email (required)').fill(email); |
|||
await page.getByRole('tab', { name: 'Collections' }).click(); |
|||
await page.getByRole('combobox', { name: 'Permission' }).click(); |
|||
await page.getByText('Edit items', { exact: true }).click(); |
|||
await page.getByLabel('Select collections').click(); |
|||
await page.getByText('Default collection').click(); |
|||
await page.getByRole('cell', { name: 'Collection', exact: true }).click(); |
|||
await page.getByRole('button', { name: 'Save' }).click(); |
|||
await utils.checkNotification(page, 'User(s) invited'); |
|||
}); |
|||
} |
|||
|
|||
export async function confirm(test, page: Page, name: string, user_email: string) { |
|||
await test.step(`Confirm ${user_email}`, async () => { |
|||
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); |
|||
await page.getByRole('row').filter({hasText: user_email}).getByLabel('Options').click(); |
|||
await page.getByRole('menuitem', { name: 'Confirm' }).click(); |
|||
await expect(page.getByRole('heading', { name: 'Confirm user' })).toBeVisible(); |
|||
await page.getByRole('button', { name: 'Confirm' }).click(); |
|||
await utils.checkNotification(page, 'confirmed'); |
|||
}); |
|||
} |
|||
|
|||
export async function revoke(test, page: Page, name: string, user_email: string) { |
|||
await test.step(`Revoke ${user_email}`, async () => { |
|||
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); |
|||
await page.getByRole('row').filter({hasText: user_email}).getByLabel('Options').click(); |
|||
await page.getByRole('menuitem', { name: 'Revoke access' }).click(); |
|||
await expect(page.getByRole('heading', { name: 'Revoke access' })).toBeVisible(); |
|||
await page.getByRole('button', { name: 'Revoke access' }).click(); |
|||
await utils.checkNotification(page, 'Revoked organisation access'); |
|||
}); |
|||
} |
@ -0,0 +1,18 @@ |
|||
import { test, expect, type TestInfo } from '@playwright/test'; |
|||
|
|||
const { exec } = require('node:child_process'); |
|||
const utils = require('../../global-utils'); |
|||
|
|||
utils.loadEnv(); |
|||
|
|||
test.beforeAll('Setup', async () => { |
|||
console.log("Starting Keycloak"); |
|||
exec(`docker compose --profile keycloak --env-file test.env up`); |
|||
}); |
|||
|
|||
test('Keycloak is up', async ({ page }) => { |
|||
await utils.waitFor(process.env.SSO_AUTHORITY, page.context().browser()); |
|||
// Dummy authority is created at the end of the setup
|
|||
await utils.waitFor(process.env.DUMMY_AUTHORITY, page.context().browser()); |
|||
console.log(`Keycloak running on: ${process.env.SSO_AUTHORITY}`); |
|||
}); |
@ -0,0 +1,15 @@ |
|||
import { test, type FullConfig } from '@playwright/test'; |
|||
|
|||
const { execSync } = require('node:child_process'); |
|||
const utils = require('../../global-utils'); |
|||
|
|||
utils.loadEnv(); |
|||
|
|||
test('Keycloak teardown', async () => { |
|||
if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { |
|||
console.log("Keep Keycloak running"); |
|||
} else { |
|||
console.log("Keycloak stopping"); |
|||
execSync(`docker compose --profile keycloak --env-file test.env stop Keycloak`); |
|||
} |
|||
}); |
@ -0,0 +1,138 @@ |
|||
import { expect, type Page, Test } from '@playwright/test'; |
|||
import { type MailBuffer, MailServer } from 'maildev'; |
|||
import * as OTPAuth from "otpauth"; |
|||
|
|||
import * as utils from '../../global-utils'; |
|||
import { retrieveEmailCode } from './2fa'; |
|||
|
|||
/** |
|||
* If a MailBuffer is passed it will be used and consume the expected emails |
|||
*/ |
|||
export async function logNewUser( |
|||
test: Test, |
|||
page: Page, |
|||
user: { email: string, name: string, password: string }, |
|||
options: { mailBuffer?: MailBuffer, override?: boolean } = {} |
|||
) { |
|||
await test.step(`Create user ${user.name}`, async () => { |
|||
await page.context().clearCookies(); |
|||
|
|||
await test.step('Landing page', async () => { |
|||
await utils.cleanLanding(page); |
|||
|
|||
if( options.override ) { |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
} else { |
|||
await page.getByLabel(/Email address/).fill(user.email); |
|||
await page.getByRole('button', { name: /Use single sign-on/ }).click(); |
|||
} |
|||
}); |
|||
|
|||
await test.step('Keycloak login', async () => { |
|||
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); |
|||
await page.getByLabel(/Username/).fill(user.name); |
|||
await page.getByLabel('Password', { exact: true }).fill(user.password); |
|||
await page.getByRole('button', { name: 'Sign In' }).click(); |
|||
}); |
|||
|
|||
await test.step('Create Vault account', async () => { |
|||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); |
|||
await page.getByLabel('New master password (required)', { exact: true }).fill(user.password); |
|||
await page.getByLabel('Confirm new master password (').fill(user.password); |
|||
await page.getByRole('button', { name: 'Create account' }).click(); |
|||
}); |
|||
|
|||
await test.step('Default vault page', async () => { |
|||
await expect(page).toHaveTitle(/Vaultwarden Web/); |
|||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); |
|||
}); |
|||
|
|||
await utils.checkNotification(page, 'Account successfully created!'); |
|||
await utils.checkNotification(page, 'Invitation accepted'); |
|||
|
|||
if( options.mailBuffer ){ |
|||
let mailBuffer = options.mailBuffer; |
|||
await test.step('Check emails', async () => { |
|||
await mailBuffer.expect((m) => m.subject === "Welcome"); |
|||
await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); |
|||
}); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* If a MailBuffer is passed it will be used and consume the expected emails |
|||
*/ |
|||
export async function logUser( |
|||
test: Test, |
|||
page: Page, |
|||
user: { email: string, password: string }, |
|||
options: { |
|||
mailBuffer ?: MailBuffer, |
|||
override?: boolean, |
|||
totp?: OTPAuth.TOTP, |
|||
mail2fa?: boolean, |
|||
} = {} |
|||
) { |
|||
let mailBuffer = options.mailBuffer; |
|||
|
|||
await test.step(`Log user ${user.email}`, async () => { |
|||
await page.context().clearCookies(); |
|||
|
|||
await test.step('Landing page', async () => { |
|||
await utils.cleanLanding(page); |
|||
|
|||
if( options.override ) { |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
} else { |
|||
await page.getByLabel(/Email address/).fill(user.email); |
|||
await page.getByRole('button', { name: /Use single sign-on/ }).click(); |
|||
} |
|||
}); |
|||
|
|||
await test.step('Keycloak login', async () => { |
|||
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); |
|||
await page.getByLabel(/Username/).fill(user.name); |
|||
await page.getByLabel('Password', { exact: true }).fill(user.password); |
|||
await page.getByRole('button', { name: 'Sign In' }).click(); |
|||
}); |
|||
|
|||
if( options.totp || options.mail2fa ){ |
|||
let code; |
|||
|
|||
await test.step('2FA check', async () => { |
|||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); |
|||
|
|||
if( options.totp ) { |
|||
const totp = options.totp; |
|||
let timestamp = Date.now(); // Needed to use the next token
|
|||
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; |
|||
code = totp.generate({timestamp}); |
|||
} else if( options.mail2fa ){ |
|||
code = await retrieveEmailCode(test, page, mailBuffer); |
|||
} |
|||
|
|||
await page.getByLabel(/Verification code/).fill(code); |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
}); |
|||
} |
|||
|
|||
await test.step('Unlock vault', async () => { |
|||
await expect(page).toHaveTitle('Vaultwarden Web'); |
|||
await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible(); |
|||
await page.getByLabel('Master password').fill(user.password); |
|||
await page.getByRole('button', { name: 'Unlock' }).click(); |
|||
}); |
|||
|
|||
await test.step('Default vault page', async () => { |
|||
await expect(page).toHaveTitle(/Vaultwarden Web/); |
|||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); |
|||
}); |
|||
|
|||
if( mailBuffer ){ |
|||
await test.step('Check email', async () => { |
|||
await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); |
|||
}); |
|||
} |
|||
}); |
|||
} |
@ -0,0 +1,55 @@ |
|||
import { expect, type Browser, Page } from '@playwright/test'; |
|||
|
|||
import { type MailBuffer } from 'maildev'; |
|||
|
|||
import * as utils from '../../global-utils'; |
|||
|
|||
export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) { |
|||
await test.step(`Create user ${user.name}`, async () => { |
|||
await utils.cleanLanding(page); |
|||
|
|||
await page.getByRole('link', { name: 'Create account' }).click(); |
|||
|
|||
// Back to Vault create account
|
|||
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); |
|||
await page.getByLabel(/Email address/).fill(user.email); |
|||
await page.getByLabel('Name').fill(user.name); |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
|
|||
// Vault finish Creation
|
|||
await page.getByLabel('New master password (required)', { exact: true }).fill(user.password); |
|||
await page.getByLabel('Confirm new master password (').fill(user.password); |
|||
await page.getByRole('button', { name: 'Create account' }).click(); |
|||
|
|||
await utils.checkNotification(page, 'Your new account has been created') |
|||
|
|||
// We are now in the default vault page
|
|||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); |
|||
await utils.checkNotification(page, 'You have been logged in!'); |
|||
|
|||
if( mailBuffer ){ |
|||
await mailBuffer.expect((m) => m.subject === "Welcome"); |
|||
await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) { |
|||
await test.step(`Log user ${user.email}`, async () => { |
|||
await utils.cleanLanding(page); |
|||
|
|||
await page.getByLabel(/Email address/).fill(user.email); |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
|
|||
// Unlock page
|
|||
await page.getByLabel('Master password').fill(user.password); |
|||
await page.getByRole('button', { name: 'Log in with master password' }).click(); |
|||
|
|||
// We are now in the default vault page
|
|||
await expect(page).toHaveTitle(/Vaultwarden Web/); |
|||
|
|||
if( mailBuffer ){ |
|||
await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); |
|||
} |
|||
}); |
|||
} |
@ -0,0 +1,53 @@ |
|||
import { test, expect, type TestInfo } from '@playwright/test'; |
|||
import { MailDev } from 'maildev'; |
|||
|
|||
import { logNewUser, logUser } from './setups/sso'; |
|||
import { activateEmail, disableEmail } from './setups/2fa'; |
|||
import * as utils from "../global-utils"; |
|||
|
|||
let users = utils.loadEnv(); |
|||
|
|||
let mailserver; |
|||
|
|||
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
|||
mailserver = new MailDev({ |
|||
port: process.env.MAILDEV_SMTP_PORT, |
|||
web: { port: process.env.MAILDEV_HTTP_PORT }, |
|||
}) |
|||
|
|||
await mailserver.listen(); |
|||
|
|||
await utils.startVault(browser, testInfo, { |
|||
SSO_ENABLED: true, |
|||
SSO_ONLY: false, |
|||
SMTP_HOST: process.env.MAILDEV_HOST, |
|||
SMTP_FROM: process.env.PW_SMTP_FROM, |
|||
}); |
|||
}); |
|||
|
|||
test.afterAll('Teardown', async ({}) => { |
|||
utils.stopVault(); |
|||
if( mailserver ){ |
|||
await mailserver.close(); |
|||
} |
|||
}); |
|||
|
|||
test('Create and activate 2FA', async ({ page }) => { |
|||
const mailBuffer = mailserver.buffer(users.user1.email); |
|||
|
|||
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer}); |
|||
|
|||
await activateEmail(test, page, users.user1, mailBuffer); |
|||
|
|||
mailBuffer.close(); |
|||
}); |
|||
|
|||
test('Log and disable', async ({ page }) => { |
|||
const mailBuffer = mailserver.buffer(users.user1.email); |
|||
|
|||
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true}); |
|||
|
|||
await disableEmail(test, page, users.user1); |
|||
|
|||
mailBuffer.close(); |
|||
}); |
@ -0,0 +1,94 @@ |
|||
import { test, expect, type TestInfo } from '@playwright/test'; |
|||
|
|||
import { logNewUser, logUser } from './setups/sso'; |
|||
import { activateTOTP, disableTOTP } from './setups/2fa'; |
|||
import * as utils from "../global-utils"; |
|||
|
|||
let users = utils.loadEnv(); |
|||
|
|||
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
|||
await utils.startVault(browser, testInfo, { |
|||
SSO_ENABLED: true, |
|||
SSO_ONLY: false |
|||
}); |
|||
}); |
|||
|
|||
test.afterAll('Teardown', async ({}) => { |
|||
utils.stopVault(); |
|||
}); |
|||
|
|||
test('Account creation using SSO', async ({ page }) => { |
|||
// Landing page
|
|||
await logNewUser(test, page, users.user1); |
|||
}); |
|||
|
|||
test('SSO login', async ({ page }) => { |
|||
await logUser(test, page, users.user1); |
|||
}); |
|||
|
|||
test('Non SSO login', async ({ page }) => { |
|||
// Landing page
|
|||
await page.goto('/'); |
|||
await page.getByLabel(/Email address/).fill(users.user1.email); |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
|
|||
// Unlock page
|
|||
await page.getByLabel('Master password').fill(users.user1.password); |
|||
await page.getByRole('button', { name: 'Log in with master password' }).click(); |
|||
|
|||
// We are now in the default vault page
|
|||
await expect(page).toHaveTitle(/Vaultwarden Web/); |
|||
}); |
|||
|
|||
test('SSO login with TOTP 2fa', async ({ page }) => { |
|||
await logUser(test, page, users.user1); |
|||
|
|||
let totp = await activateTOTP(test, page, users.user1); |
|||
|
|||
await logUser(test, page, users.user1, { totp }); |
|||
|
|||
await disableTOTP(test, page, users.user1); |
|||
}); |
|||
|
|||
test('Non SSO login impossible', async ({ page, browser }, testInfo: 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); |
|||
|
|||
await page.getByLabel(/Email address/).fill(users.user1.email); |
|||
await page.getByRole('button', { name: 'Continue' }).click(); |
|||
|
|||
// Unlock page
|
|||
await page.getByLabel('Master password').fill(users.user1.password); |
|||
await page.getByRole('button', { name: 'Log in with master password' }).click(); |
|||
|
|||
// An error should appear
|
|||
await page.getByLabel('SSO sign-in is required') |
|||
}); |
|||
|
|||
|
|||
test('No SSO login', async ({ page }, testInfo: TestInfo) => { |
|||
await utils.restartVault(page, testInfo, { |
|||
SSO_ENABLED: false |
|||
}, false); |
|||
|
|||
// Landing page
|
|||
await page.goto('/'); |
|||
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); |
|||
}); |
@ -0,0 +1,121 @@ |
|||
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(); |
|||
|
|||
let mailServer, mail1Buffer, mail2Buffer, mail3Buffer; |
|||
|
|||
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
|||
mailServer = new MailDev({ |
|||
port: process.env.MAILDEV_SMTP_PORT, |
|||
web: { port: process.env.MAILDEV_HTTP_PORT }, |
|||
}) |
|||
|
|||
await mailServer.listen(); |
|||
|
|||
await utils.startVault(browser, testInfo, { |
|||
SMTP_HOST: process.env.MAILDEV_HOST, |
|||
SMTP_FROM: process.env.PW_SMTP_FROM, |
|||
SSO_ENABLED: true, |
|||
SSO_ONLY: true, |
|||
}); |
|||
|
|||
mail1Buffer = mailServer.buffer(users.user1.email); |
|||
mail2Buffer = mailServer.buffer(users.user2.email); |
|||
mail3Buffer = mailServer.buffer(users.user3.email); |
|||
}); |
|||
|
|||
test.afterAll('Teardown', async ({}) => { |
|||
utils.stopVault(); |
|||
[mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); |
|||
}); |
|||
|
|||
test('Create user3', async ({ page }) => { |
|||
await logNewUser(test, page, users.user3, { mailBuffer: mail3Buffer }); |
|||
}); |
|||
|
|||
test('Invite users', async ({ page }) => { |
|||
await logNewUser(test, page, users.user1, { mailBuffer: mail1Buffer }); |
|||
|
|||
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.expect((m) => m.subject === "Join /Test"); |
|||
await page.setContent(invited.html); |
|||
return await page.getByTestId("invite").getAttribute("href"); |
|||
}); |
|||
|
|||
await test.step('Redirect to Keycloak', async () => { |
|||
await page.goto(link); |
|||
}); |
|||
|
|||
await test.step('Keycloak login', async () => { |
|||
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); |
|||
await page.getByLabel(/Username/).fill(users.user2.name); |
|||
await page.getByLabel('Password', { exact: true }).fill(users.user2.password); |
|||
await page.getByRole('button', { name: 'Sign In' }).click(); |
|||
}); |
|||
|
|||
await test.step('Create Vault account', async () => { |
|||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); |
|||
await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password); |
|||
await page.getByLabel('Confirm new master password (').fill(users.user2.password); |
|||
await page.getByRole('button', { name: 'Create account' }).click(); |
|||
}); |
|||
|
|||
await test.step('Default vault page', async () => { |
|||
await expect(page).toHaveTitle(/Vaultwarden Web/); |
|||
|
|||
await utils.checkNotification(page, 'Account successfully created!'); |
|||
await utils.checkNotification(page, 'Invitation accepted'); |
|||
}); |
|||
|
|||
await test.step('Check mails', async () => { |
|||
await mail2Buffer.expect((m) => m.subject.includes("New Device Logged")); |
|||
await mail1Buffer.expect((m) => m.subject === "Invitation to /Test accepted"); |
|||
}); |
|||
}); |
|||
|
|||
test('invited with existing account', async ({ page }) => { |
|||
const link = await test.step('Extract email link', async () => { |
|||
const invited = await mail3Buffer.expect((m) => m.subject === "Join /Test"); |
|||
await page.setContent(invited.html); |
|||
return await page.getByTestId("invite").getAttribute("href"); |
|||
}); |
|||
|
|||
await test.step('Redirect to Keycloak', async () => { |
|||
await page.goto(link); |
|||
}); |
|||
|
|||
await test.step('Keycloak login', async () => { |
|||
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); |
|||
await page.getByLabel(/Username/).fill(users.user3.name); |
|||
await page.getByLabel('Password', { exact: true }).fill(users.user3.password); |
|||
await page.getByRole('button', { name: 'Sign In' }).click(); |
|||
}); |
|||
|
|||
await test.step('Unlock vault', async () => { |
|||
await expect(page).toHaveTitle('Vaultwarden Web'); |
|||
await page.getByLabel('Master password').fill(users.user3.password); |
|||
await page.getByRole('button', { name: 'Unlock' }).click(); |
|||
}); |
|||
|
|||
await test.step('Default vault page', async () => { |
|||
await expect(page).toHaveTitle(/Vaultwarden Web/); |
|||
await utils.checkNotification(page, 'Invitation accepted'); |
|||
}); |
|||
|
|||
await test.step('Check mails', async () => { |
|||
await mail3Buffer.expect((m) => m.subject.includes("New Device Logged")); |
|||
await mail1Buffer.expect((m) => m.subject === "Invitation to /Test accepted"); |
|||
}); |
|||
}); |
@ -0,0 +1,76 @@ |
|||
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.startVault(browser, testInfo, { |
|||
SSO_ENABLED: true, |
|||
SSO_ONLY: true, |
|||
}); |
|||
}); |
|||
|
|||
test.afterAll('Teardown', async ({}) => { |
|||
utils.stopVault(); |
|||
}); |
|||
|
|||
test('Create user3', async ({ page }) => { |
|||
await logNewUser(test, page, users.user3); |
|||
}); |
|||
|
|||
test('Invite users', async ({ page }) => { |
|||
await logNewUser(test, page, users.user1); |
|||
|
|||
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 }) => { |
|||
await logNewUser(test, page, users.user2); |
|||
}); |
|||
|
|||
test('Confirm invited user', async ({ page }) => { |
|||
await logUser(test, page, users.user1); |
|||
await orgs.members(test, page, '/Test'); |
|||
await expect(page.getByRole('row', { name: users.user2.name })).toHaveText(/Needs confirmation/); |
|||
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 expect(page.getByLabel('Filter: Default collection')).toBeVisible(); |
|||
}); |
|||
|
|||
test('Enforce password policy', async ({ page }) => { |
|||
await logUser(test, page, users.user1); |
|||
await orgs.policies(test, page, '/Test'); |
|||
|
|||
await test.step(`Set master password policy`, async () => { |
|||
await page.getByRole('button', { name: 'Master password requirements' }).click(); |
|||
await page.getByRole('checkbox', { name: 'Turn on' }).check(); |
|||
await page.getByRole('checkbox', { name: 'Require existing members to' }).check(); |
|||
await page.getByRole('spinbutton', { name: 'Minimum length' }).fill('42'); |
|||
await page.getByRole('button', { name: 'Save' }).click(); |
|||
await utils.checkNotification(page, 'Edited policy Master password requirements.'); |
|||
}); |
|||
|
|||
await utils.logout(test, page, users.user1); |
|||
|
|||
await test.step(`Unlock trigger policy`, async () => { |
|||
await page.getByRole('textbox', { name: 'Email address (required)' }).fill(users.user1.email); |
|||
await page.getByRole('button', { name: 'Use single sign-on' }).click(); |
|||
|
|||
await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password); |
|||
await page.getByRole('button', { name: 'Unlock' }).click(); |
|||
|
|||
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible(); |
|||
}); |
|||
}); |
@ -0,0 +1,89 @@ |
|||
use chrono::{NaiveDateTime, Utc}; |
|||
|
|||
use crate::api::EmptyResult; |
|||
use crate::db::{DbConn, DbPool}; |
|||
use crate::error::MapResult; |
|||
use crate::sso::{OIDCState, NONCE_EXPIRATION}; |
|||
|
|||
db_object! { |
|||
#[derive(Identifiable, Queryable, Insertable)] |
|||
#[diesel(table_name = sso_nonce)] |
|||
#[diesel(primary_key(state))] |
|||
pub struct SsoNonce { |
|||
pub state: OIDCState, |
|||
pub nonce: String, |
|||
pub verifier: Option<String>, |
|||
pub redirect_uri: String, |
|||
pub created_at: NaiveDateTime, |
|||
} |
|||
} |
|||
|
|||
/// Local methods
|
|||
impl SsoNonce { |
|||
pub fn new(state: OIDCState, nonce: String, verifier: Option<String>, redirect_uri: String) -> Self { |
|||
let now = Utc::now().naive_utc(); |
|||
|
|||
SsoNonce { |
|||
state, |
|||
nonce, |
|||
verifier, |
|||
redirect_uri, |
|||
created_at: now, |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// Database methods
|
|||
impl SsoNonce { |
|||
pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { |
|||
db_run! { conn: |
|||
sqlite, mysql { |
|||
diesel::replace_into(sso_nonce::table) |
|||
.values(SsoNonceDb::to_db(self)) |
|||
.execute(conn) |
|||
.map_res("Error saving SSO nonce") |
|||
} |
|||
postgresql { |
|||
let value = SsoNonceDb::to_db(self); |
|||
diesel::insert_into(sso_nonce::table) |
|||
.values(&value) |
|||
.execute(conn) |
|||
.map_res("Error saving SSO nonce") |
|||
} |
|||
} |
|||
} |
|||
|
|||
pub async fn delete(state: &OIDCState, conn: &mut DbConn) -> EmptyResult { |
|||
db_run! { conn: { |
|||
diesel::delete(sso_nonce::table.filter(sso_nonce::state.eq(state))) |
|||
.execute(conn) |
|||
.map_res("Error deleting SSO nonce") |
|||
}} |
|||
} |
|||
|
|||
pub async fn find(state: &OIDCState, conn: &DbConn) -> Option<Self> { |
|||
let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION; |
|||
db_run! { conn: { |
|||
sso_nonce::table |
|||
.filter(sso_nonce::state.eq(state)) |
|||
.filter(sso_nonce::created_at.ge(oldest)) |
|||
.first::<SsoNonceDb>(conn) |
|||
.ok() |
|||
.from_db() |
|||
}} |
|||
} |
|||
|
|||
pub async fn delete_expired(pool: DbPool) -> EmptyResult { |
|||
debug!("Purging expired sso_nonce"); |
|||
if let Ok(conn) = pool.get().await { |
|||
let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION; |
|||
db_run! { conn: { |
|||
diesel::delete(sso_nonce::table.filter(sso_nonce::created_at.lt(oldest))) |
|||
.execute(conn) |
|||
.map_res("Error deleting expired SSO nonce") |
|||
}} |
|||
} else { |
|||
err!("Failed to get DB connection while purging expired sso_nonce") |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,462 @@ |
|||
use chrono::Utc; |
|||
use derive_more::{AsRef, Deref, Display, From}; |
|||
use regex::Regex; |
|||
use std::time::Duration; |
|||
use url::Url; |
|||
|
|||
use mini_moka::sync::Cache; |
|||
use once_cell::sync::Lazy; |
|||
|
|||
use crate::{ |
|||
api::ApiResult, |
|||
auth, |
|||
auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, |
|||
db::{ |
|||
models::{Device, SsoNonce, User}, |
|||
DbConn, |
|||
}, |
|||
sso_client::Client, |
|||
CONFIG, |
|||
}; |
|||
|
|||
pub static FAKE_IDENTIFIER: &str = "Vaultwarden"; |
|||
|
|||
static AC_CACHE: Lazy<Cache<OIDCState, AuthenticatedUser>> = |
|||
Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build()); |
|||
|
|||
static SSO_JWT_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|sso", CONFIG.domain_origin())); |
|||
|
|||
pub static NONCE_EXPIRATION: Lazy<chrono::Duration> = Lazy::new(|| chrono::TimeDelta::try_minutes(10).unwrap()); |
|||
|
|||
#[derive(
|
|||
Clone, |
|||
Debug, |
|||
Default, |
|||
DieselNewType, |
|||
FromForm, |
|||
PartialEq, |
|||
Eq, |
|||
Hash, |
|||
Serialize, |
|||
Deserialize, |
|||
AsRef, |
|||
Deref, |
|||
Display, |
|||
From, |
|||
)] |
|||
#[deref(forward)] |
|||
#[from(forward)] |
|||
pub struct OIDCCode(String); |
|||
|
|||
#[derive(
|
|||
Clone, |
|||
Debug, |
|||
Default, |
|||
DieselNewType, |
|||
FromForm, |
|||
PartialEq, |
|||
Eq, |
|||
Hash, |
|||
Serialize, |
|||
Deserialize, |
|||
AsRef, |
|||
Deref, |
|||
Display, |
|||
From, |
|||
)] |
|||
#[deref(forward)] |
|||
#[from(forward)] |
|||
pub struct OIDCState(String); |
|||
|
|||
#[derive(Debug, Serialize, Deserialize)] |
|||
struct SsoTokenJwtClaims { |
|||
// Not before
|
|||
pub nbf: i64, |
|||
// Expiration time
|
|||
pub exp: i64, |
|||
// Issuer
|
|||
pub iss: String, |
|||
// Subject
|
|||
pub sub: String, |
|||
} |
|||
|
|||
pub fn encode_ssotoken_claims() -> String { |
|||
let time_now = Utc::now(); |
|||
let claims = SsoTokenJwtClaims { |
|||
nbf: time_now.timestamp(), |
|||
exp: (time_now + chrono::TimeDelta::try_minutes(2).unwrap()).timestamp(), |
|||
iss: SSO_JWT_ISSUER.to_string(), |
|||
sub: "vaultwarden".to_string(), |
|||
}; |
|||
|
|||
auth::encode_jwt(&claims) |
|||
} |
|||
|
|||
#[derive(Debug, Serialize, Deserialize)] |
|||
pub enum OIDCCodeWrapper { |
|||
Ok { |
|||
state: OIDCState, |
|||
code: OIDCCode, |
|||
}, |
|||
Error { |
|||
state: OIDCState, |
|||
error: String, |
|||
error_description: Option<String>, |
|||
}, |
|||
} |
|||
|
|||
#[derive(Debug, Serialize, Deserialize)] |
|||
struct OIDCCodeClaims { |
|||
// Expiration time
|
|||
pub exp: i64, |
|||
// Issuer
|
|||
pub iss: String, |
|||
|
|||
pub code: OIDCCodeWrapper, |
|||
} |
|||
|
|||
pub fn encode_code_claims(code: OIDCCodeWrapper) -> String { |
|||
let time_now = Utc::now(); |
|||
let claims = OIDCCodeClaims { |
|||
exp: (time_now + chrono::TimeDelta::try_minutes(5).unwrap()).timestamp(), |
|||
iss: SSO_JWT_ISSUER.to_string(), |
|||
code, |
|||
}; |
|||
|
|||
auth::encode_jwt(&claims) |
|||
} |
|||
|
|||
#[derive(Clone, Debug, Serialize, Deserialize)] |
|||
struct BasicTokenClaims { |
|||
iat: Option<i64>, |
|||
nbf: Option<i64>, |
|||
exp: i64, |
|||
} |
|||
|
|||
impl BasicTokenClaims { |
|||
fn nbf(&self) -> i64 { |
|||
self.nbf.or(self.iat).unwrap_or_else(|| Utc::now().timestamp()) |
|||
} |
|||
} |
|||
|
|||
fn decode_token_claims(token_name: &str, token: &str) -> ApiResult<BasicTokenClaims> { |
|||
let mut validation = jsonwebtoken::Validation::default(); |
|||
validation.set_issuer(&[CONFIG.sso_authority()]); |
|||
validation.insecure_disable_signature_validation(); |
|||
validation.validate_aud = false; |
|||
|
|||
match jsonwebtoken::decode(token, &jsonwebtoken::DecodingKey::from_secret(&[]), &validation) { |
|||
Ok(btc) => Ok(btc.claims), |
|||
Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")), |
|||
} |
|||
} |
|||
|
|||
pub fn deocde_state(base64_state: String) -> ApiResult<OIDCState> { |
|||
let state = match data_encoding::BASE64.decode(base64_state.as_bytes()) { |
|||
Ok(vec) => match String::from_utf8(vec) { |
|||
Ok(valid) => OIDCState(valid), |
|||
Err(_) => err!(format!("Invalid utf8 chars in {base64_state} after base64 decoding")), |
|||
}, |
|||
Err(_) => err!(format!("Failed to decode {base64_state} using base64")), |
|||
}; |
|||
|
|||
Ok(state) |
|||
} |
|||
|
|||
// The `nonce` allow to protect against replay attacks
|
|||
// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs
|
|||
pub async fn authorize_url( |
|||
state: OIDCState, |
|||
client_id: &str, |
|||
raw_redirect_uri: &str, |
|||
mut conn: DbConn, |
|||
) -> ApiResult<Url> { |
|||
let redirect_uri = match client_id { |
|||
"web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()), |
|||
"desktop" | "mobile" => "bitwarden://sso-callback".to_string(), |
|||
"cli" => { |
|||
let port_regex = Regex::new(r"^http://localhost:([0-9]{4})$").unwrap(); |
|||
match port_regex.captures(raw_redirect_uri).and_then(|captures| captures.get(1).map(|c| c.as_str())) { |
|||
Some(port) => format!("http://localhost:{port}"), |
|||
None => err!("Failed to extract port number"), |
|||
} |
|||
} |
|||
_ => err!(format!("Unsupported client {client_id}")), |
|||
}; |
|||
|
|||
let (auth_url, nonce) = Client::authorize_url(state, redirect_uri).await?; |
|||
nonce.save(&mut conn).await?; |
|||
Ok(auth_url) |
|||
} |
|||
|
|||
#[derive(
|
|||
Clone, |
|||
Debug, |
|||
Default, |
|||
DieselNewType, |
|||
FromForm, |
|||
PartialEq, |
|||
Eq, |
|||
Hash, |
|||
Serialize, |
|||
Deserialize, |
|||
AsRef, |
|||
Deref, |
|||
Display, |
|||
From, |
|||
)] |
|||
#[deref(forward)] |
|||
#[from(forward)] |
|||
pub struct OIDCIdentifier(String); |
|||
|
|||
impl OIDCIdentifier { |
|||
fn new(issuer: &str, subject: &str) -> Self { |
|||
OIDCIdentifier(format!("{issuer}/{subject}")) |
|||
} |
|||
} |
|||
|
|||
#[derive(Clone, Debug)] |
|||
pub struct AuthenticatedUser { |
|||
pub refresh_token: Option<String>, |
|||
pub access_token: String, |
|||
pub expires_in: Option<Duration>, |
|||
pub identifier: OIDCIdentifier, |
|||
pub email: String, |
|||
pub email_verified: Option<bool>, |
|||
pub user_name: Option<String>, |
|||
} |
|||
|
|||
#[derive(Clone, Debug)] |
|||
pub struct UserInformation { |
|||
pub state: OIDCState, |
|||
pub identifier: OIDCIdentifier, |
|||
pub email: String, |
|||
pub email_verified: Option<bool>, |
|||
pub user_name: Option<String>, |
|||
} |
|||
|
|||
async fn decode_code_claims(code: &str, conn: &mut DbConn) -> ApiResult<(OIDCCode, OIDCState)> { |
|||
match auth::decode_jwt::<OIDCCodeClaims>(code, SSO_JWT_ISSUER.to_string()) { |
|||
Ok(code_claims) => match code_claims.code { |
|||
OIDCCodeWrapper::Ok { |
|||
state, |
|||
code, |
|||
} => Ok((code, state)), |
|||
OIDCCodeWrapper::Error { |
|||
state, |
|||
error, |
|||
error_description, |
|||
} => { |
|||
if let Err(err) = SsoNonce::delete(&state, conn).await { |
|||
error!("Failed to delete database sso_nonce using {state}: {err}") |
|||
} |
|||
err!(format!( |
|||
"SSO authorization failed: {error}, {}", |
|||
error_description.as_ref().unwrap_or(&String::new()) |
|||
)) |
|||
} |
|||
}, |
|||
Err(err) => err!(format!("Failed to decode code wrapper: {err}")), |
|||
} |
|||
} |
|||
|
|||
// During the 2FA flow we will
|
|||
// - retrieve the user information and then only discover he needs 2FA.
|
|||
// - second time we will rely on the `AC_CACHE` since the `code` has already been exchanged.
|
|||
// The `nonce` will ensure that the user is authorized only once.
|
|||
// We return only the `UserInformation` to force calling `redeem` to obtain the `refresh_token`.
|
|||
pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult<UserInformation> { |
|||
use openidconnect::OAuth2TokenResponse; |
|||
|
|||
let (code, state) = decode_code_claims(wrapped_code, conn).await?; |
|||
|
|||
if let Some(authenticated_user) = AC_CACHE.get(&state) { |
|||
return Ok(UserInformation { |
|||
state, |
|||
identifier: authenticated_user.identifier, |
|||
email: authenticated_user.email, |
|||
email_verified: authenticated_user.email_verified, |
|||
user_name: authenticated_user.user_name, |
|||
}); |
|||
} |
|||
|
|||
let nonce = match SsoNonce::find(&state, conn).await { |
|||
None => err!(format!("Invalid state cannot retrieve nonce")), |
|||
Some(nonce) => nonce, |
|||
}; |
|||
|
|||
let client = Client::cached().await?; |
|||
let (token_response, id_claims) = client.exchange_code(code, nonce).await?; |
|||
|
|||
let user_info = client.user_info(token_response.access_token().to_owned()).await?; |
|||
|
|||
let email = match id_claims.email().or(user_info.email()) { |
|||
None => err!("Neither id token nor userinfo contained an email"), |
|||
Some(e) => e.to_string().to_lowercase(), |
|||
}; |
|||
|
|||
let email_verified = id_claims.email_verified().or(user_info.email_verified()); |
|||
|
|||
let user_name = id_claims.preferred_username().map(|un| un.to_string()); |
|||
|
|||
let refresh_token = token_response.refresh_token().map(|t| t.secret()); |
|||
if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) { |
|||
error!("Scope offline_access is present but response contain no refresh_token"); |
|||
} |
|||
|
|||
let identifier = OIDCIdentifier::new(id_claims.issuer(), id_claims.subject()); |
|||
|
|||
let authenticated_user = AuthenticatedUser { |
|||
refresh_token: refresh_token.cloned(), |
|||
access_token: token_response.access_token().secret().clone(), |
|||
expires_in: token_response.expires_in(), |
|||
identifier: identifier.clone(), |
|||
email: email.clone(), |
|||
email_verified, |
|||
user_name: user_name.clone(), |
|||
}; |
|||
|
|||
debug!("Authentified user {authenticated_user:?}"); |
|||
|
|||
AC_CACHE.insert(state.clone(), authenticated_user); |
|||
|
|||
Ok(UserInformation { |
|||
state, |
|||
identifier, |
|||
email, |
|||
email_verified, |
|||
user_name, |
|||
}) |
|||
} |
|||
|
|||
// User has passed 2FA flow we can delete `nonce` and clear the cache.
|
|||
pub async fn redeem(state: &OIDCState, conn: &mut DbConn) -> ApiResult<AuthenticatedUser> { |
|||
if let Err(err) = SsoNonce::delete(state, conn).await { |
|||
error!("Failed to delete database sso_nonce using {state}: {err}") |
|||
} |
|||
|
|||
if let Some(au) = AC_CACHE.get(state) { |
|||
AC_CACHE.invalidate(state); |
|||
Ok(au) |
|||
} else { |
|||
err!("Failed to retrieve user info from sso cache") |
|||
} |
|||
} |
|||
|
|||
// We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front).
|
|||
// If there is no SSO refresh_token, we keep the access_token to be able to call user_info to check for validity
|
|||
pub fn create_auth_tokens( |
|||
device: &Device, |
|||
user: &User, |
|||
client_id: Option<String>, |
|||
refresh_token: Option<String>, |
|||
access_token: String, |
|||
expires_in: Option<Duration>, |
|||
) -> ApiResult<AuthTokens> { |
|||
if !CONFIG.sso_auth_only_not_session() { |
|||
let now = Utc::now(); |
|||
|
|||
let (ap_nbf, ap_exp) = match (decode_token_claims("access_token", &access_token), expires_in) { |
|||
(Ok(ap), _) => (ap.nbf(), ap.exp), |
|||
(Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()), |
|||
_ => err!("Non jwt access_token and empty expires_in"), |
|||
}; |
|||
|
|||
let access_claims = |
|||
auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now); |
|||
|
|||
_create_auth_tokens(device, refresh_token, access_claims, access_token) |
|||
} else { |
|||
Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id)) |
|||
} |
|||
} |
|||
|
|||
fn _create_auth_tokens( |
|||
device: &Device, |
|||
refresh_token: Option<String>, |
|||
access_claims: auth::LoginJwtClaims, |
|||
access_token: String, |
|||
) -> ApiResult<AuthTokens> { |
|||
let (nbf, exp, token) = if let Some(rt) = refresh_token { |
|||
match decode_token_claims("refresh_token", &rt) { |
|||
Err(_) => { |
|||
let time_now = Utc::now(); |
|||
let exp = (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp(); |
|||
debug!("Non jwt refresh_token (expiration set to {exp})"); |
|||
(time_now.timestamp(), exp, TokenWrapper::Refresh(rt)) |
|||
} |
|||
Ok(refresh_payload) => { |
|||
debug!("Refresh_payload: {refresh_payload:?}"); |
|||
(refresh_payload.nbf(), refresh_payload.exp, TokenWrapper::Refresh(rt)) |
|||
} |
|||
} |
|||
} else { |
|||
debug!("No refresh_token present"); |
|||
(access_claims.nbf, access_claims.exp, TokenWrapper::Access(access_token)) |
|||
}; |
|||
|
|||
let refresh_claims = auth::RefreshJwtClaims { |
|||
nbf, |
|||
exp, |
|||
iss: auth::JWT_LOGIN_ISSUER.to_string(), |
|||
sub: AuthMethod::Sso, |
|||
device_token: device.refresh_token.clone(), |
|||
token: Some(token), |
|||
}; |
|||
|
|||
Ok(AuthTokens { |
|||
refresh_claims, |
|||
access_claims, |
|||
}) |
|||
} |
|||
|
|||
// This endpoint is called in two case
|
|||
// - the session is close to expiration we will try to extend it
|
|||
// - the user is going to make an action and we check that the session is still valid
|
|||
pub async fn exchange_refresh_token( |
|||
device: &Device, |
|||
user: &User, |
|||
client_id: Option<String>, |
|||
refresh_claims: auth::RefreshJwtClaims, |
|||
) -> ApiResult<AuthTokens> { |
|||
let exp = refresh_claims.exp; |
|||
match refresh_claims.token { |
|||
Some(TokenWrapper::Refresh(refresh_token)) => { |
|||
// Use new refresh_token if returned
|
|||
let (new_refresh_token, access_token, expires_in) = |
|||
Client::exchange_refresh_token(refresh_token.clone()).await?; |
|||
|
|||
create_auth_tokens( |
|||
device, |
|||
user, |
|||
client_id, |
|||
new_refresh_token.or(Some(refresh_token)), |
|||
access_token, |
|||
expires_in, |
|||
) |
|||
} |
|||
Some(TokenWrapper::Access(access_token)) => { |
|||
let now = Utc::now(); |
|||
let exp_limit = (now + *BW_EXPIRATION).timestamp(); |
|||
|
|||
if exp < exp_limit { |
|||
err_silent!("Access token is close to expiration but we have no refresh token") |
|||
} |
|||
|
|||
Client::check_validaty(access_token.clone()).await?; |
|||
|
|||
let access_claims = auth::LoginJwtClaims::new( |
|||
device, |
|||
user, |
|||
now.timestamp(), |
|||
exp, |
|||
AuthMethod::Sso.scope_vec(), |
|||
client_id, |
|||
now, |
|||
); |
|||
|
|||
_create_auth_tokens(device, None, access_claims, access_token) |
|||
} |
|||
None => err!("No token present while in SSO"), |
|||
} |
|||
} |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue