committed by
GitHub
102 changed files with 8060 additions and 428 deletions
File diff suppressed because it is too large
@ -0,0 +1,293 @@ |
|||||
|
# SSO using OpenId Connect |
||||
|
|
||||
|
To use an external source of authentication your SSO will need to support OpenID Connect : |
||||
|
|
||||
|
- An OpenID Connect Discovery endpoint should be available |
||||
|
- Client authentication will be done using Id and Secret. |
||||
|
|
||||
|
A master password will still be required and not controlled by the SSO (depending on your point of view this might be a feature ;). |
||||
|
This introduces another way to control who can use the vault without having to use invitation or using an LDAP. |
||||
|
|
||||
|
## Configuration |
||||
|
|
||||
|
The following configurations are available |
||||
|
|
||||
|
- `SSO_ENABLED` : Activate the SSO |
||||
|
- `SSO_ONLY` : disable email+Master password authentication |
||||
|
- `SSO_SIGNUPS_MATCH_EMAIL`: On SSO Signup if a user with a matching email already exists make the association (default `true`) |
||||
|
- `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`: Allow unknown email verification status (default `false`). Allowing this with `SSO_SIGNUPS_MATCH_EMAIL` open potential account takeover. |
||||
|
- `SSO_AUTHORITY` : the OpenID Connect Discovery endpoint of your SSO |
||||
|
- Should not include the `/.well-known/openid-configuration` part and no trailing `/` |
||||
|
- $SSO_AUTHORITY/.well-known/openid-configuration should return the a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse |
||||
|
- `SSO_SCOPES` : Optional, allow to override scopes if needed (default `"email profile"`) |
||||
|
- `SSO_AUTHORIZE_EXTRA_PARAMS` : Optional, allow to add extra parameter to the authorize redirection (default `""`) |
||||
|
- `SSO_PKCE`: Activate PKCE for the Auth Code flow (default `true`). |
||||
|
- `SSO_AUDIENCE_TRUSTED`: Optional, Regex to trust additional audience for the IdToken (`client_id` is always trusted). Use single quote when writing the regex: `'^$'`. |
||||
|
- `SSO_CLIENT_ID` : Client Id |
||||
|
- `SSO_CLIENT_SECRET` : Client Secret |
||||
|
- `SSO_MASTER_PASSWORD_POLICY`: Optional Master password policy (`enforceOnLogin` is not supported). |
||||
|
- `SSO_AUTH_ONLY_NOT_SESSION`: Enable to use SSO only for authentication not session lifecycle |
||||
|
- `SSO_CLIENT_CACHE_EXPIRATION`: Cache calls to the discovery endpoint, duration in seconds, `0` to disable (default `0`); |
||||
|
- `SSO_DEBUG_TOKENS`: Log all tokens (default `false`, `LOG_LEVEL=debug` is required) |
||||
|
|
||||
|
The callback url is : `https://your.domain/identity/connect/oidc-signin` |
||||
|
|
||||
|
## Account and Email handling |
||||
|
|
||||
|
When logging in with SSO an identifier (`{iss}/{sub}` claims from the IdToken) is saved in a separate table (`sso_users`). |
||||
|
This is used to link to the SSO provider identifier without changing the default Vaultwarden user `uuid`. This is needed because: |
||||
|
|
||||
|
- Storing the SSO identifier is important to prevent account takeover due to email change. |
||||
|
- We can't use the identifier as the User uuid since it's way longer (Max 255 chars for the `sub` part, cf [spec](https://openid.net/specs/openid-connect-core-1_0.html#IDToken)). |
||||
|
- We want to be able to associate existing account based on `email` but only when the user logs in for the first time (controlled by `SSO_SIGNUPS_MATCH_EMAIL`). |
||||
|
- We need to be able to associate with existing stub account, such as the one created when inviting a user to an org (association is possible only if the user does not have a private key). |
||||
|
|
||||
|
Additionally: |
||||
|
|
||||
|
- Signup to Vaultwarden will be blocked if the Provider reports the email as `unverified`. |
||||
|
- Changing the email needs to be done by the user since it requires updating the `key`. |
||||
|
On login if the email returned by the provider is not the one saved in Vaultwarden an email will be sent to the user to ask him to update it. |
||||
|
- If set `SIGNUPS_DOMAINS_WHITELIST` is applied on SSO signup and when attempting to change the email. |
||||
|
|
||||
|
This means that if you ever need to change the provider url or the provider itself; you'll have to first delete the association |
||||
|
then ensure that `SSO_SIGNUPS_MATCH_EMAIL` is activated to allow a new association. |
||||
|
|
||||
|
To delete the association (this has no impact on the `Vaultwarden` user): |
||||
|
|
||||
|
```sql |
||||
|
TRUNCATE TABLE sso_users; |
||||
|
``` |
||||
|
|
||||
|
### On `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION` |
||||
|
|
||||
|
If your provider does not send the verification status of emails (`email_verified` [claim](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims)) you will need to activate this setting. |
||||
|
|
||||
|
If set with `SSO_SIGNUPS_MATCH_EMAIL=true` (the default), then a user can associate with an existing, non-SSO account, even if they do not control the email address. |
||||
|
This allow a user to gain access to sensitive information but the master password is still required to read the passwords. |
||||
|
|
||||
|
As such when using `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION` it is recommended to disable `SSO_SIGNUPS_MATCH_EMAIL`. |
||||
|
If you need to associate non sso users try to keep both settings activated for the shortest time possible. |
||||
|
|
||||
|
## Client Cache |
||||
|
|
||||
|
By default the client cache is disabled since it can cause issues with the signing keys. |
||||
|
\ |
||||
|
This means that the discovery endpoint will be called again each time we need to interact with the provider (generating authorize_url, exchange the authorize code, refresh tokens). |
||||
|
This is suboptimal so the `SSO_CLIENT_CACHE_EXPIRATION` allows you to configure an expiration that should work for your provider. |
||||
|
|
||||
|
As a protection against a misconfigured expiration if the validation of the `IdToken` fails then the client cache is invalidated (but you'll periodically have an unlucky user ^^). |
||||
|
|
||||
|
### Google example (Rolling keys) |
||||
|
|
||||
|
If we take Google as an example checking the discovery [endpoint](https://accounts.google.com/.well-known/openid-configuration) response headers we can see that the `max-age` of the cache control is set to `3600` seconds. And the [jwk_uri](https://www.googleapis.com/oauth2/v3/certs) response headers usually contain a `max-age` with an even bigger value. |
||||
|
/ |
||||
|
Combined with user [feedback](https://github.com/ramosbugs/openidconnect-rs/issues/152) we can conclude that Google will roll the signing keys each week. |
||||
|
|
||||
|
Setting the cache expiration too high has diminishing return but using something like `600` (10 min) should provide plenty benefits. |
||||
|
|
||||
|
### Rolling keys manually |
||||
|
|
||||
|
If you want to roll the used key, first add a new one but do not immediately start signing with it. |
||||
|
Wait for the delay you configured in `SSO_CLIENT_CACHE_EXPIRATION` then you can start signing with it. |
||||
|
|
||||
|
As mentioned in the Google example setting too high of a value has diminishing return even if you do not plan to roll the keys. |
||||
|
|
||||
|
## Keycloak |
||||
|
|
||||
|
Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `VaultWarden` front-end expiration detection which is also set at `5min`. |
||||
|
\ |
||||
|
At the realm level |
||||
|
|
||||
|
- `Realm settings / Tokens / Access Token Lifespan` to at least `10min` (`accessTokenLifespan` setting when using `kcadm.sh`). |
||||
|
- `Realm settings / Sessions / SSO Session Idle/Max` for the Refresh token lifetime |
||||
|
|
||||
|
Or for a specific client in `Clients / Client details / Advanced / Advanced settings` you can find `Access Token Lifespan` and `Client Session Idle/Max`. |
||||
|
|
||||
|
Server configuration, nothing specific just set: |
||||
|
|
||||
|
- `SSO_AUTHORITY=https://${domain}/realms/${realm_name}` |
||||
|
- `SSO_CLIENT_ID` |
||||
|
- `SSO_CLIENT_SECRET` |
||||
|
|
||||
|
### Testing |
||||
|
|
||||
|
If you want to run a testing instance of Keycloak the Playwright [docker-compose](playwright/docker-compose.yml) can be used. |
||||
|
\ |
||||
|
More details on how to use it in [README.md](playwright/README.md#openid-connect-test-setup). |
||||
|
|
||||
|
|
||||
|
## Auth0 |
||||
|
|
||||
|
Not working due to the following issue https://github.com/ramosbugs/openidconnect-rs/issues/23 (they appear not to follow the spec). |
||||
|
A feature flag is available to bypass the issue but since it's a compile time feature you will have to patch `Vaultwarden` with something like: |
||||
|
|
||||
|
```patch |
||||
|
diff --git a/Cargo.toml b/Cargo.toml |
||||
|
index 0524a7be..9999e852 100644 |
||||
|
--- a/Cargo.toml |
||||
|
+++ b/Cargo.toml |
||||
|
@@ -150,7 +150,7 @@ paste = "1.0.15" |
||||
|
governor = "0.6.3" |
||||
|
|
||||
|
# OIDC for SSO |
||||
|
-openidconnect = "3.5.0" |
||||
|
+openidconnect = { version = "3.5.0", features = ["accept-rfc3339-timestamps"] } |
||||
|
mini-moka = "0.10.2" |
||||
|
``` |
||||
|
|
||||
|
There is no plan at the moment to either always activate the feature nor make a specific distribution for Auth0. |
||||
|
|
||||
|
## Authelia |
||||
|
|
||||
|
To obtain a `refresh_token` to be able to extend session you'll need to add the `offline_access` scope. |
||||
|
|
||||
|
Config will look like: |
||||
|
|
||||
|
- `SSO_SCOPES="email profile offline_access"` |
||||
|
|
||||
|
|
||||
|
## Authentik |
||||
|
|
||||
|
Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `VaultWarden` front-end expiration detection which is also set at `5min`. |
||||
|
\ |
||||
|
To change the tokens expiration go to `Applications / Providers / Edit / Advanced protocol settings`. |
||||
|
|
||||
|
Starting with `2024.2` version you will need to add the `offline_access` scope and ensure it's selected in `Applications / Providers / Edit / Advanced protocol settings / Scopes` ([Doc](https://docs.goauthentik.io/docs/providers/oauth2/#authorization_code)). |
||||
|
|
||||
|
Server configuration should look like: |
||||
|
|
||||
|
- `SSO_AUTHORITY=https://${domain}/application/o/${application_name}/` : trailing `/` is important |
||||
|
- `SSO_SCOPES="email profile offline_access"` |
||||
|
- `SSO_CLIENT_ID` |
||||
|
- `SSO_CLIENT_SECRET` |
||||
|
|
||||
|
## Casdoor |
||||
|
|
||||
|
Since version [v1.639.0](https://github.com/casdoor/casdoor/releases/tag/v1.639.0) should work (Tested with version [v1.686.0](https://github.com/casdoor/casdoor/releases/tag/v1.686.0)). |
||||
|
When creating the application you will need to select the `Token format -> JWT-Standard`. |
||||
|
|
||||
|
Then configure your server with: |
||||
|
|
||||
|
- `SSO_AUTHORITY=https://${provider_host}` |
||||
|
- `SSO_CLIENT_ID` |
||||
|
- `SSO_CLIENT_SECRET` |
||||
|
|
||||
|
## GitLab |
||||
|
|
||||
|
Create an application in your Gitlab Settings with |
||||
|
|
||||
|
- `redirectURI`: https://your.domain/identity/connect/oidc-signin |
||||
|
- `Confidential`: `true` |
||||
|
- `scopes`: `openid`, `profile`, `email` |
||||
|
|
||||
|
Then configure your server with |
||||
|
|
||||
|
- `SSO_AUTHORITY=https://gitlab.com` |
||||
|
- `SSO_CLIENT_ID` |
||||
|
- `SSO_CLIENT_SECRET` |
||||
|
|
||||
|
## Google Auth |
||||
|
|
||||
|
Google [Documentation](https://developers.google.com/identity/openid-connect/openid-connect). |
||||
|
\ |
||||
|
By default without extra [configuration](https://developers.google.com/identity/protocols/oauth2/web-server#creatingclient) you won´t have a `refresh_token` and session will be limited to 1h. |
||||
|
|
||||
|
Configure your server with : |
||||
|
|
||||
|
- `SSO_AUTHORITY=https://accounts.google.com` |
||||
|
- `SSO_AUTHORIZE_EXTRA_PARAMS="access_type=offline&prompt=consent"` |
||||
|
- `SSO_CLIENT_ID` |
||||
|
- `SSO_CLIENT_SECRET` |
||||
|
|
||||
|
## Kanidm |
||||
|
|
||||
|
Nothing specific should work with just `SSO_AUTHORITY`, `SSO_CLIENT_ID` and `SSO_CLIENT_SECRET`. |
||||
|
|
||||
|
## Microsoft Entra ID |
||||
|
|
||||
|
1. Create an "App registration" in [Entra ID](https://entra.microsoft.com/) following [Identity | Applications | App registrations](https://entra.microsoft.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade/quickStartType//sourceType/Microsoft_AAD_IAM). |
||||
|
2. From the "Overview" of your "App registration", you'll need the "Directory (tenant) ID" for the `SSO_AUTHORITY` variable and the "Application (client) ID" as the `SSO_CLIENT_ID` value. |
||||
|
3. In "Certificates & Secrets" create an "App secret" , you'll need the "Secret Value" for the `SSO_CLIENT_SECRET` variable. |
||||
|
4. In "Authentication" add <https://vaultwarden.example.org/identity/connect/oidc-signin> as "Web Redirect URI". |
||||
|
5. In "API Permissions" make sure you have `profile`, `email` and `offline_access` listed under "API / Permission name" (`offline_access` is required, otherwise no refresh_token is returned, see <https://github.com/MicrosoftDocs/azure-docs/issues/17134>). |
||||
|
|
||||
|
Only the v2 endpoint is compliant with the OpenID spec, see <https://github.com/MicrosoftDocs/azure-docs/issues/38427> and <https://github.com/ramosbugs/openidconnect-rs/issues/122>. |
||||
|
|
||||
|
Your configuration should look like this: |
||||
|
|
||||
|
* `SSO_AUTHORITY=https://login.microsoftonline.com/${Directory (tenant) ID}/v2.0` |
||||
|
* `SSO_SCOPES="email profile offline_access"` |
||||
|
* `SSO_CLIENT_ID=${Application (client) ID}` |
||||
|
* `SSO_CLIENT_SECRET=${Secret Value}` |
||||
|
|
||||
|
## Zitadel |
||||
|
|
||||
|
To obtain a `refresh_token` to be able to extend session you'll need to add the `offline_access` scope. |
||||
|
|
||||
|
Additionally Zitadel include the `Project id` and the `Client Id` in the audience of the Id Token. |
||||
|
For the validation to work you will need to add the `Resource Id` as a trusted audience (`Client Id` is trusted by default). |
||||
|
You can control the trusted audience with the config `SSO_AUDIENCE_TRUSTED` |
||||
|
|
||||
|
It appears it's not possible to use PKCE with confidential client so it needs to be disabled. |
||||
|
|
||||
|
Config will look like: |
||||
|
|
||||
|
- `SSO_AUTHORITY=https://${provider_host}` |
||||
|
- `SSO_SCOPES="email profile offline_access"` |
||||
|
- `SSO_CLIENT_ID` |
||||
|
- `SSO_CLIENT_SECRET` |
||||
|
- `SSO_AUDIENCE_TRUSTED='^${Project Id}$'` |
||||
|
- `SSO_PKCE=false` |
||||
|
|
||||
|
## Session lifetime |
||||
|
|
||||
|
Session lifetime is dependant on refresh token and access token returned after calling your SSO token endpoint (grant type `authorization_code`). |
||||
|
If no refresh token is returned then the session will be limited to the access token lifetime. |
||||
|
|
||||
|
Tokens are not persisted in VaultWarden but wrapped in JWT tokens and returned to the application (The `refresh_token` and `access_token` values returned by VW `identity/connect/token` endpoint). |
||||
|
Note that VaultWarden will always return a `refresh_token` for compatibility reasons with the web front and it presence does not indicate that a refresh token was returned by your SSO (But you can decode its value with <https://jwt.io> and then check if the `token` field contain anything). |
||||
|
|
||||
|
With a refresh token present, activity in the application will trigger a refresh of the access token when it's close to expiration ([5min](https://github.com/bitwarden/clients/blob/0bcb45ed5caa990abaff735553a5046e85250f24/libs/common/src/auth/services/token.service.ts#L126) in web client). |
||||
|
|
||||
|
Additionally for certain action a token check is performed, if we have a refresh token we will perform a refresh otherwise we'll call the user information endpoint to check the access token validity. |
||||
|
|
||||
|
### Disabling SSO session handling |
||||
|
|
||||
|
If you are unable to obtain a `refresh_token` or for any other reason you can disable SSO session handling and revert to the default handling. |
||||
|
You'll need to enable `SSO_AUTH_ONLY_NOT_SESSION=true` then access token will be valid for 2h and refresh token will allow for an idle time of 7 days (which can be indefinitely extended). |
||||
|
|
||||
|
### Debug information |
||||
|
|
||||
|
Running with `LOG_LEVEL=debug` you'll be able to see information on token expiration. |
||||
|
|
||||
|
## Desktop Client |
||||
|
|
||||
|
There is some issue to handle redirection from your browser (used for sso login) to the application. |
||||
|
|
||||
|
### Chrome |
||||
|
|
||||
|
Probably not much hope, an [issue](https://github.com/bitwarden/clients/issues/2606) is open on the subject and it appears that both Linux and Windows are not working. |
||||
|
|
||||
|
## Firefox |
||||
|
|
||||
|
On Windows you'll be presented with a prompt the first time you log to confirm which application should be launched (But there is a bug at the moment you might end-up with an empty vault after login atm). |
||||
|
|
||||
|
|
||||
|
On Linux it's a bit more tricky. |
||||
|
First you'll need to add some config in `about:config` : |
||||
|
|
||||
|
```conf |
||||
|
network.protocol-handler.expose.bitwarden=false |
||||
|
network.protocol-handler.external.bitwarden=true |
||||
|
``` |
||||
|
|
||||
|
If you have any doubt you can check `mailto` to see how it's configured. |
||||
|
|
||||
|
The redirection will still not work since it appears that the association to an application can only be done on a link/click. You can trigger it with a dummy page such as: |
||||
|
|
||||
|
```html |
||||
|
data:text/html,<a href="bitwarden:///dummy">Click me to register Bitwarden</a> |
||||
|
``` |
||||
|
|
||||
|
From now on the redirection should now work. |
||||
|
If you need to change the application launched you can now find it in `Settings` by using the search function and entering `application`. |
@ -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,63 @@ |
|||||
|
################################# |
||||
|
### 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} |
||||
|
I_REALLY_WANT_VOLATILE_STORAGE=true |
||||
|
|
||||
|
SSO_ENABLED=true |
||||
|
SSO_ONLY=false |
||||
|
SSO_CLIENT_ID=VaultWarden |
||||
|
SSO_CLIENT_SECRET=VaultWarden |
||||
|
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,177 @@ |
|||||
|
# 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 |
||||
|
|
||||
|
Additionnaly 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 and the default `web-vault` you'll need to make the SSO button visible using : |
||||
|
|
||||
|
```bash |
||||
|
sed -i 's#a\[routerlink="/sso"\],##' web-vault/app/main.*.css |
||||
|
``` |
||||
|
|
||||
|
Otherwise you'll need to reveal the SSO login button using the debug console (F12) |
||||
|
|
||||
|
```js |
||||
|
document.querySelector('a[routerlink="/sso"]').style.setProperty("display", "inline-block", "important"); |
||||
|
``` |
||||
|
|
||||
|
## Rebuilding the Vaultwarden |
||||
|
|
||||
|
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,39 @@ |
|||||
|
FROM playwright_oidc_vaultwarden_prebuilt AS vaultwarden |
||||
|
|
||||
|
FROM node:18-bookworm AS build |
||||
|
|
||||
|
arg REPO_URL |
||||
|
arg COMMIT_HASH |
||||
|
|
||||
|
ENV REPO_URL=$REPO_URL |
||||
|
ENV COMMIT_HASH=$COMMIT_HASH |
||||
|
|
||||
|
COPY --from=vaultwarden /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=vaultwarden /start.sh . |
||||
|
COPY --from=vaultwarden /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,121 @@ |
|||||
|
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/vaultwarden |
||||
|
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 |
||||
|
- SMTP_HOST |
||||
|
- SMTP_FROM |
||||
|
- SMTP_DEBUG |
||||
|
- 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,212 @@ |
|||||
|
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": 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": 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": return { |
||||
|
DATABASE_URL: `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PASSWORD}@127.0.0.1:${process.env.MYSQL_PORT}/${process.env.MYSQL_DATABASE}` |
||||
|
} |
||||
|
default: return { I_REALLY_WANT_VOLATILE_STORAGE: true } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* All parameters passed in `env` need to be added to the docker-compose.yml |
||||
|
**/ |
||||
|
export async function startVaultwarden(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) { |
||||
|
if( resetDB ){ |
||||
|
switch(testInfo.project.name) { |
||||
|
case "postgres": |
||||
|
await wipePostgres(); |
||||
|
break; |
||||
|
case "mariadb": |
||||
|
await wipeMariaDB(); |
||||
|
break; |
||||
|
case "mysql": |
||||
|
await wipeMysqlDB(); |
||||
|
break; |
||||
|
default: |
||||
|
wipeSqlite(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
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 stopVaultwarden() { |
||||
|
console.log(`Vaultwarden stopping`); |
||||
|
execSync(`docker compose --profile playwright --env-file test.env stop Vaultwarden`); |
||||
|
} |
||||
|
|
||||
|
export async function restartVaultwarden(page: Page, testInfo: TestInfo, env, resetDB: Boolean = true) { |
||||
|
stopVaultwarden(); |
||||
|
return startVaultwarden(page.context().browser(), testInfo, env, resetDB); |
||||
|
} |
||||
|
|
||||
|
export async function 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); |
||||
|
} |
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.49.1", |
||||
|
"dotenv": "^16.4.7", |
||||
|
"dotenv-expand": "^11.0.7", |
||||
|
"maildev": "github:timshel/maildev#3.0.4" |
||||
|
}, |
||||
|
"dependencies": { |
||||
|
"mysql2": "^3.12.0", |
||||
|
"otpauth": "^9.3.6", |
||||
|
"pg": "^8.13.1" |
||||
|
} |
||||
|
} |
@ -0,0 +1,137 @@ |
|||||
|
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, |
||||
|
|
||||
|
/* Retry on CI only */ |
||||
|
retries: 0, |
||||
|
workers: 1, |
||||
|
|
||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ |
||||
|
reporter: 'html', |
||||
|
timeout: 20 * 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', |
||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ |
||||
|
trace: 'on-first-retry', |
||||
|
viewport: { |
||||
|
width: 1920, |
||||
|
height: 1080 |
||||
|
}, |
||||
|
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,89 @@ |
|||||
|
################################################################## |
||||
|
### 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} |
||||
|
VAULTWARDEN_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} |
||||
|
SMTP_SECURITY=off |
||||
|
SMTP_PORT=${MAILDEV_SMTP_PORT} |
||||
|
SMTP_FROM_NAME=Vaultwarden |
||||
|
SMTP_TIMEOUT=5 |
||||
|
|
||||
|
SSO_CLIENT_ID=VaultWarden |
||||
|
SSO_CLIENT_SECRET=VaultWarden |
||||
|
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} |
||||
|
|
||||
|
########################### |
||||
|
# Docker MariaDb container# |
||||
|
########################### |
||||
|
MARIADB_PORT=3307 |
||||
|
MARIADB_ROOT_PASSWORD=vaultwarden |
||||
|
MARIADB_USER=vaultwarden |
||||
|
MARIADB_PASSWORD=vaultwarden |
||||
|
MARIADB_DATABASE=vaultwarden |
||||
|
|
||||
|
########################### |
||||
|
# Docker Mysql container# |
||||
|
########################### |
||||
|
MYSQL_PORT=3309 |
||||
|
MYSQL_ROOT_PASSWORD=vaultwarden |
||||
|
MYSQL_USER=vaultwarden |
||||
|
MYSQL_PASSWORD=vaultwarden |
||||
|
MYSQL_DATABASE=vaultwarden |
||||
|
|
||||
|
############################ |
||||
|
# Docker Postgres container# |
||||
|
############################ |
||||
|
POSTGRES_PORT=5433 |
||||
|
POSTGRES_USER=vaultwarden |
||||
|
POSTGRES_PASSWORD=vaultwarden |
||||
|
POSTGRES_DB=vaultwarden |
@ -0,0 +1,38 @@ |
|||||
|
import { test, expect, type TestInfo } from '@playwright/test'; |
||||
|
|
||||
|
import * as utils from "../global-utils"; |
||||
|
import { createAccount, logUser } from './setups/user'; |
||||
|
|
||||
|
let users = utils.loadEnv(); |
||||
|
|
||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
||||
|
await utils.startVaultwarden(browser, testInfo); |
||||
|
}); |
||||
|
|
||||
|
test.afterAll('Teardown', async ({}) => { |
||||
|
utils.stopVaultwarden(); |
||||
|
}); |
||||
|
|
||||
|
test('Create', async ({ page }) => { |
||||
|
await createAccount(test, page, users.user1); |
||||
|
await logUser(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,130 @@ |
|||||
|
import { test, expect, type TestInfo } from '@playwright/test'; |
||||
|
import { MailDev } from 'maildev'; |
||||
|
|
||||
|
const utils = require('../global-utils'); |
||||
|
import { createAccount, logUser } from './setups/user'; |
||||
|
|
||||
|
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.startVaultwarden(browser, testInfo, { |
||||
|
SMTP_HOST: process.env.MAILDEV_HOST, |
||||
|
SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test.afterAll('Teardown', async ({}) => { |
||||
|
utils.stopVaultwarden(); |
||||
|
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.next((m) => m.subject === "Verify Your Email"); |
||||
|
expect(verify.from[0]?.address).toBe(process.env.VAULTWARDEN_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('Activaite 2fa', async ({ context, page }) => { |
||||
|
const emails = mailserver.buffer(users.user1.email); |
||||
|
|
||||
|
await logUser(test, page, users.user1); |
||||
|
|
||||
|
await page.getByRole('button', { name: users.user1.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('li').filter({ hasText: 'Email' }).getByRole('button').click(); |
||||
|
await page.getByLabel('Master password (required)').fill(users.user1.password); |
||||
|
await page.getByRole('button', { name: 'Continue' }).click(); |
||||
|
await page.getByRole('button', { name: 'Send email' }).click(); |
||||
|
|
||||
|
const codeMail = await emails.next((mail) => mail.subject === "Vaultwarden Login Verification Code"); |
||||
|
const page2 = await context.newPage(); |
||||
|
await page2.setContent(codeMail.html); |
||||
|
const code = await page2.getByTestId("2fa").innerText(); |
||||
|
await page2.close(); |
||||
|
|
||||
|
await page.getByLabel('2. Enter the resulting 6').fill(code); |
||||
|
await page.getByRole('button', { name: 'Turn on' }).click(); |
||||
|
await page.getByRole('heading', { name: 'Turned on', exact: true }); |
||||
|
|
||||
|
emails.close(); |
||||
|
}); |
||||
|
|
||||
|
test('2fa', async ({ context, 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(); |
||||
|
|
||||
|
const codeMail = await emails.next((mail) => mail.subject === "Vaultwarden Login Verification Code"); |
||||
|
const page2 = await context.newPage(); |
||||
|
await page2.setContent(codeMail.html); |
||||
|
const code = await page2.getByTestId("2fa").innerText(); |
||||
|
await page2.close(); |
||||
|
|
||||
|
await page.getByLabel('Verification code').fill(code); |
||||
|
await page.getByRole('button', { name: 'Continue' }).click(); |
||||
|
|
||||
|
await expect(page).toHaveTitle(/Vaultwarden Web/); |
||||
|
}) |
||||
|
|
||||
|
await test.step('disable', 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('li').filter({ hasText: 'Email' }).getByRole('button').click(); |
||||
|
await page.getByLabel('Master password (required)').click(); |
||||
|
await page.getByLabel('Master password (required)').fill(users.user1.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'); |
||||
|
}); |
||||
|
|
||||
|
emails.close(); |
||||
|
}); |
@ -0,0 +1,94 @@ |
|||||
|
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'; |
||||
|
|
||||
|
let users = utils.loadEnv(); |
||||
|
let totp; |
||||
|
|
||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
||||
|
await utils.startVaultwarden(browser, testInfo, {}); |
||||
|
}); |
||||
|
|
||||
|
test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { |
||||
|
utils.stopVaultwarden(testInfo); |
||||
|
}); |
||||
|
|
||||
|
test('Account creation', async ({ page }) => { |
||||
|
// Landing page
|
||||
|
await createAccount(test, page, users.user1); |
||||
|
|
||||
|
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('Master password login', async ({ page }) => { |
||||
|
await logUser(test, page, users.user1); |
||||
|
}); |
||||
|
|
||||
|
test('Authenticator 2fa', async ({ context, page }) => { |
||||
|
let totp; |
||||
|
|
||||
|
await test.step('Login', async () => { |
||||
|
await logUser(test, page, users.user1); |
||||
|
}); |
||||
|
|
||||
|
await test.step('Activate', async () => { |
||||
|
await page.getByRole('button', { name: users.user1.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('li').filter({ hasText: 'TOTP Authenticator' }).getByRole('button').click(); |
||||
|
await page.getByLabel('Master password (required)').fill(users.user1.password); |
||||
|
await page.getByRole('button', { name: 'Continue' }).click(); |
||||
|
|
||||
|
const secret = await page.getByLabel('Key').innerText(); |
||||
|
totp = new OTPAuth.TOTP({ secret, period: 30 }); |
||||
|
|
||||
|
await page.getByLabel('Verification code (required)').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(); |
||||
|
}) |
||||
|
|
||||
|
await test.step('logout', async () => { |
||||
|
await page.getByRole('button', { name: users.user1.name }).click(); |
||||
|
await page.getByRole('menuitem', { name: 'Log out' }).click(); |
||||
|
}); |
||||
|
|
||||
|
await test.step('login', async () => { |
||||
|
let timestamp = Date.now(); // Need 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 page.getByLabel('Verification code').fill(totp.generate({timestamp})); |
||||
|
await page.getByRole('button', { name: 'Continue' }).click(); |
||||
|
|
||||
|
await expect(page).toHaveTitle(/Vaultwarden Web/); |
||||
|
}); |
||||
|
|
||||
|
await test.step('disable', 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('li').filter({ hasText: 'TOTP Authenticator' }).getByRole('button').click(); |
||||
|
await page.getByLabel('Master password (required)').click(); |
||||
|
await page.getByLabel('Master password (required)').fill(users.user1.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,126 @@ |
|||||
|
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.startVaultwarden(browser, testInfo, { |
||||
|
SMTP_HOST: process.env.MAILDEV_HOST, |
||||
|
SMTP_FROM: process.env.VAULTWARDEN_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.stopVaultwarden(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 logUser(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.next((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('Master password\n (required)', { exact: true }).fill(users.user2.password); |
||||
|
await page.getByLabel('Re-type master password').fill(users.user2.password); |
||||
|
await page.getByRole('button', { name: 'Create account' }).click(); |
||||
|
|
||||
|
// Back to the login page
|
||||
|
await utils.checkNotification(page, 'Your new account has been created'); |
||||
|
}); |
||||
|
|
||||
|
await test.step('Login', async () => { |
||||
|
await page.getByLabel(/Email address/).fill(users.user2.email); |
||||
|
await page.getByRole('button', { name: 'Continue' }).click(); |
||||
|
|
||||
|
// Unlock page
|
||||
|
await page.getByLabel('Master password').fill(users.user2.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 test.step('Check mails', async () => { |
||||
|
await expect(mail2Buffer.next((m) => m.subject === 'Welcome')).resolves.toBeDefined(); |
||||
|
await expect(mail2Buffer.next((m) => m.subject === 'New Device Logged In From Firefox')).resolves.toBeDefined(); |
||||
|
await expect(mail1Buffer.next((m) => m.subject.includes('Invitation to Test accepted'))).resolves.toBeDefined(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test('invited with existing account', async ({ page }) => { |
||||
|
const invited = await mail3Buffer.next((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 expect(mail3Buffer.next((m) => m.subject === 'New Device Logged In From Firefox')).resolves.toBeDefined(); |
||||
|
await expect(mail1Buffer.next((m) => m.subject.includes('Invitation to Test accepted'))).resolves.toBeDefined(); |
||||
|
}); |
||||
|
|
||||
|
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.name); |
||||
|
|
||||
|
await expect(mail2Buffer.next((m) => m.subject.includes('Invitation to Test confirmed'))).resolves.toBeDefined(); |
||||
|
}); |
||||
|
|
||||
|
test('Organization is visible', async ({ page }) => { |
||||
|
await logUser(test, page, users.user2, mail2Buffer); |
||||
|
await page.getByLabel('vault: Test').click(); |
||||
|
await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); |
||||
|
}); |
@ -0,0 +1,91 @@ |
|||||
|
import { test, expect, type TestInfo } from '@playwright/test'; |
||||
|
import { MailDev } from 'maildev'; |
||||
|
|
||||
|
import * as utils from "../global-utils"; |
||||
|
import { createAccount, logUser } from './setups/user'; |
||||
|
|
||||
|
let users = utils.loadEnv(); |
||||
|
|
||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
||||
|
await utils.startVaultwarden(browser, testInfo); |
||||
|
}); |
||||
|
|
||||
|
test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { |
||||
|
utils.stopVaultwarden(testInfo); |
||||
|
}); |
||||
|
|
||||
|
test('Create user3', async ({ page }) => { |
||||
|
await createAccount(test, page, users.user3); |
||||
|
}); |
||||
|
|
||||
|
test('Invite users', async ({ page }) => { |
||||
|
await createAccount(test, page, users.user1); |
||||
|
await logUser(test, page, users.user1); |
||||
|
|
||||
|
await test.step('Create Org', async () => { |
||||
|
await page.getByRole('link', { name: 'New organisation' }).click(); |
||||
|
await page.getByLabel('Organisation name (required)').fill('Test'); |
||||
|
await page.getByRole('button', { name: 'Submit' }).click(); |
||||
|
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); |
||||
|
}); |
||||
|
|
||||
|
await test.step('Invite user2', async () => { |
||||
|
await page.getByRole('button', { name: 'Invite member' }).click(); |
||||
|
await page.getByLabel('Email (required)').fill(users.user2.email); |
||||
|
await page.getByRole('tab', { name: 'Collections' }).click(); |
||||
|
await page.getByLabel('Permission').selectOption('edit'); |
||||
|
await page.getByLabel('Select collections').click(); |
||||
|
await page.getByLabel('Options list').getByText('Default collection').click(); |
||||
|
await page.getByRole('button', { name: 'Save' }).click(); |
||||
|
await utils.checkNotification(page, 'User(s) invited'); |
||||
|
await expect(page.getByRole('row', { name: users.user2.email })).toHaveText(/Invited/); |
||||
|
}); |
||||
|
|
||||
|
await test.step('Invite user3', async () => { |
||||
|
await page.getByRole('button', { name: 'Invite member' }).click(); |
||||
|
await page.getByLabel('Email (required)').fill(users.user3.email); |
||||
|
await page.getByRole('tab', { name: 'Collections' }).click(); |
||||
|
await page.getByLabel('Permission').selectOption('edit'); |
||||
|
await page.getByLabel('Select collections').click(); |
||||
|
await page.getByLabel('Options list').getByText('Default collection').click(); |
||||
|
await page.getByRole('button', { name: 'Save' }).click(); |
||||
|
await utils.checkNotification(page, 'User(s) invited'); |
||||
|
await expect(page.getByRole('row', { name: users.user3.name })).toHaveText(/Needs confirmation/); |
||||
|
}); |
||||
|
|
||||
|
await test.step('Confirm existing user3', async () => { |
||||
|
await page.getByRole('row', { name: users.user3.name }).getByLabel('Options').click(); |
||||
|
await page.getByRole('menuitem', { name: 'Confirm' }).click(); |
||||
|
await page.getByRole('button', { name: 'Confirm' }).click(); |
||||
|
await utils.checkNotification(page, 'confirmed'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test('Create invited account', async ({ page }) => { |
||||
|
await createAccount(test, page, users.user2); |
||||
|
}); |
||||
|
|
||||
|
test('Confirm invited user', async ({ page }) => { |
||||
|
await logUser(test, page, users.user1); |
||||
|
await page.getByLabel('Switch products').click(); |
||||
|
await page.getByRole('link', { name: ' Admin Console' }).click(); |
||||
|
await page.getByRole('link', { name: 'Members' }).click(); |
||||
|
|
||||
|
await test.step('Confirm user2', async () => { |
||||
|
await page.getByRole('row', { name: users.user2.name }).getByLabel('Options').click(); |
||||
|
await page.getByRole('menuitem', { name: 'Confirm' }).click(); |
||||
|
await page.getByRole('button', { name: 'Confirm' }).click(); |
||||
|
await utils.checkNotification(page, 'confirmed'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test('Organization is visible', async ({ context, page }) => { |
||||
|
await logUser(test, page, users.user2); |
||||
|
await page.getByLabel('vault: Test').click(); |
||||
|
await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); |
||||
|
|
||||
|
const page2 = await context.newPage(); |
||||
|
await logUser(test, page2, users.user3); |
||||
|
await page2.getByLabel('vault: Test').click(); |
||||
|
await expect(page2.getByLabel('Filter: Default collection')).toBeVisible(); |
||||
|
}); |
@ -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,62 @@ |
|||||
|
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 members(test, page: Page, name: string) { |
||||
|
await test.step(`Navigate to ${name}`, 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(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
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.getByLabel('Permission').selectOption('edit'); |
||||
|
await page.getByLabel('Select collections').click(); |
||||
|
await page.getByLabel('Options list').getByText('Default collection').click(); |
||||
|
await page.getByRole('button', { name: 'Save' }).click(); |
||||
|
await utils.checkNotification(page, 'User(s) invited'); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
export async function confirm(test, page: Page, name: string, user_name: string) { |
||||
|
await test.step(`Confirm ${user_name}`, async () => { |
||||
|
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); |
||||
|
await page.getByRole('row', { name: user_name }).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_name: string) { |
||||
|
await test.step(`Revoke ${user_name}`, async () => { |
||||
|
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); |
||||
|
await page.getByRole('row', { name: user_name }).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,19 @@ |
|||||
|
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 }) => { |
||||
|
test.setTimeout(60000); |
||||
|
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,113 @@ |
|||||
|
import { expect, type Page, Test } from '@playwright/test'; |
||||
|
import { type MailBuffer, MailServer } from 'maildev'; |
||||
|
|
||||
|
import * as utils from '../../global-utils'; |
||||
|
|
||||
|
/** |
||||
|
* 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, mailServer?: MailServer } = {} |
||||
|
) { |
||||
|
let mailBuffer = options.mailBuffer ?? options.mailServer?.buffer(user.email); |
||||
|
try { |
||||
|
await test.step('Create user', async () => { |
||||
|
await test.step('Landing page', async () => { |
||||
|
await page.goto('/'); |
||||
|
await page.getByLabel(/Email address/).fill(user.email); |
||||
|
await page.getByRole('button', 'Continue').click(); |
||||
|
}); |
||||
|
|
||||
|
await test.step('SSo start page', async () => { |
||||
|
await page.getByRole('link', { name: /Enterprise 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.getByText('Set master password')).toBeVisible(); |
||||
|
await page.getByLabel('Master password', { exact: true }).fill(user.password); |
||||
|
await page.getByLabel('Re-type master password').fill(user.password); |
||||
|
await page.getByRole('button', { name: 'Submit' }).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 emails', async () => { |
||||
|
await expect(mailBuffer.next((m) => m.subject === "Welcome")).resolves.toBeDefined(); |
||||
|
await expect(mailBuffer.next((m) => m.subject.includes("New Device Logged"))).resolves.toBeDefined(); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} finally { |
||||
|
if( options.mailServer ){ |
||||
|
mailBuffer.close(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 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, mailServer?: MailServer} = {} |
||||
|
) { |
||||
|
let mailBuffer = options.mailBuffer ?? options.mailServer?.buffer(user.email); |
||||
|
try { |
||||
|
await test.step('Log user', async () => { |
||||
|
await test.step('Landing page', async () => { |
||||
|
await page.goto('/'); |
||||
|
await page.getByLabel(/Email address/).fill(user.email); |
||||
|
await page.getByRole('button', 'Continue').click(); |
||||
|
}); |
||||
|
|
||||
|
await test.step('SSo start page', async () => { |
||||
|
await page.getByRole('link', { name: /Enterprise 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('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( options.emails ){ |
||||
|
await test.step('Check email', async () => { |
||||
|
await expect(mailBuffer.next((m) => m.subject.includes("New Device Logged"))).resolves.toBeDefined(); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} finally { |
||||
|
if( options.mailServer ){ |
||||
|
mailBuffer.close(); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,48 @@ |
|||||
|
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', async () => { |
||||
|
// Landing page
|
||||
|
await page.goto('/'); |
||||
|
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.getByLabel('Master password\n (required)', { exact: true }).fill(user.password); |
||||
|
await page.getByLabel('Re-type master password').fill(user.password); |
||||
|
await page.getByRole('button', { name: 'Create account' }).click(); |
||||
|
|
||||
|
// Back to the login page
|
||||
|
await expect(page).toHaveTitle('Vaultwarden Web'); |
||||
|
await utils.checkNotification(page, 'Your new account has been created'); |
||||
|
|
||||
|
if( mailBuffer ){ |
||||
|
await expect(mailBuffer.next((m) => m.subject === "Welcome")).resolves.toBeDefined(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) { |
||||
|
await test.step('Log user', async () => { |
||||
|
// Landing page
|
||||
|
await page.goto('/'); |
||||
|
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 expect(mailBuffer.next((m) => m.subject === 'New Device Logged In From Firefox')).resolves.toBeDefined(); |
||||
|
} |
||||
|
}); |
||||
|
} |
@ -0,0 +1,77 @@ |
|||||
|
import { test, expect, type TestInfo } from '@playwright/test'; |
||||
|
import { logNewUser, logUser } from './setups/sso'; |
||||
|
import * as utils from "../global-utils"; |
||||
|
|
||||
|
let users = utils.loadEnv(); |
||||
|
|
||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
||||
|
await utils.startVaultwarden(browser, testInfo, { |
||||
|
SSO_ENABLED: true, |
||||
|
SSO_ONLY: false |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test.afterAll('Teardown', async ({}) => { |
||||
|
utils.stopVaultwarden(); |
||||
|
}); |
||||
|
|
||||
|
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('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) => { |
||||
|
await utils.restartVaultwarden(page, testInfo, { |
||||
|
SSO_ENABLED: true, |
||||
|
SSO_ONLY: true |
||||
|
}, false); |
||||
|
|
||||
|
// 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(); |
||||
|
|
||||
|
// An error should appear
|
||||
|
await page.getByLabel('SSO sign-in is required') |
||||
|
|
||||
|
// Check the selector for the next test
|
||||
|
await expect(page.getByRole('link', { name: /Enterprise single sign-on/ })).toHaveCount(1); |
||||
|
}); |
||||
|
|
||||
|
|
||||
|
test('No SSO login', async ({ page }, testInfo: TestInfo) => { |
||||
|
await utils.restartVaultwarden(page, testInfo, { |
||||
|
SSO_ENABLED: false |
||||
|
}, false); |
||||
|
|
||||
|
// Landing page
|
||||
|
await page.goto('/'); |
||||
|
await page.getByLabel(/Email address/).fill(users.user1.email); |
||||
|
await page.getByRole('button', { name: 'Continue' }).click(); |
||||
|
|
||||
|
// No SSO button (rely on a correct selector checked in previous test)
|
||||
|
await page.getByLabel('Master password'); |
||||
|
await expect(page.getByRole('link', { name: /Enterprise single sign-on/ })).toHaveCount(0); |
||||
|
}); |
@ -0,0 +1,142 @@ |
|||||
|
import { test, expect, type TestInfo } from '@playwright/test'; |
||||
|
import { MailDev } from 'maildev'; |
||||
|
|
||||
|
import * as utils from "../global-utils"; |
||||
|
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.startVaultwarden(browser, testInfo, { |
||||
|
SMTP_HOST: process.env.MAILDEV_HOST, |
||||
|
SMTP_FROM: process.env.VAULTWARDEN_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.stopVaultwarden(); |
||||
|
[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 test.step('Create Org', async () => { |
||||
|
await page.getByRole('link', { name: 'New organisation' }).click(); |
||||
|
await page.getByLabel('Organisation name (required)').fill('Test'); |
||||
|
await page.getByRole('button', { name: 'Submit' }).click(); |
||||
|
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); |
||||
|
}); |
||||
|
|
||||
|
await test.step('Invite user2', async () => { |
||||
|
await page.getByRole('button', { name: 'Invite member' }).click(); |
||||
|
await page.getByLabel('Email (required)').fill(users.user2.email); |
||||
|
await page.getByRole('tab', { name: 'Collections' }).click(); |
||||
|
await page.getByLabel('Permission').selectOption('edit'); |
||||
|
await page.getByLabel('Select collections').click(); |
||||
|
await page.getByLabel('Options list').getByText('Default collection').click(); |
||||
|
await page.getByRole('button', { name: 'Save' }).click(); |
||||
|
await utils.checkNotification(page, 'User(s) invited'); |
||||
|
}); |
||||
|
|
||||
|
await test.step('Invite user3', async () => { |
||||
|
await page.getByRole('button', { name: 'Invite member' }).click(); |
||||
|
await page.getByLabel('Email (required)').fill(users.user3.email); |
||||
|
await page.getByRole('tab', { name: 'Collections' }).click(); |
||||
|
await page.getByLabel('Permission').selectOption('edit'); |
||||
|
await page.getByLabel('Select collections').click(); |
||||
|
await page.getByLabel('Options list').getByText('Default collection').click(); |
||||
|
await page.getByRole('button', { name: 'Save' }).click(); |
||||
|
await utils.checkNotification(page, 'User(s) invited'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test('invited with new account', async ({ page }) => { |
||||
|
const link = await test.step('Extract email link', async () => { |
||||
|
const invited = await mail2Buffer.next((m) => m.subject === "Join Test"); |
||||
|
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.getByText('Set master password')).toBeVisible(); |
||||
|
await page.getByLabel('Master password', { exact: true }).fill(users.user2.password); |
||||
|
await page.getByLabel('Re-type master password').fill(users.user2.password); |
||||
|
await page.getByRole('button', { name: 'Submit' }).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 expect(mail2Buffer.next((m) => m.subject.includes("New Device Logged"))).resolves.toBeDefined(); |
||||
|
await expect(mail1Buffer.next((m) => m.subject === "Invitation to Test accepted")).resolves.toBeDefined(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test('invited with existing account', async ({ page }) => { |
||||
|
const link = await test.step('Extract email link', async () => { |
||||
|
const invited = await mail3Buffer.next((m) => m.subject === "Join Test"); |
||||
|
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 expect(mail3Buffer.next((m) => m.subject.includes("New Device Logged"))).resolves.toBeDefined(); |
||||
|
await expect(mail1Buffer.next((m) => m.subject === "Invitation to Test accepted")).resolves.toBeDefined(); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,90 @@ |
|||||
|
import { test, expect, type TestInfo } from '@playwright/test'; |
||||
|
import { MailDev } from 'maildev'; |
||||
|
|
||||
|
import * as utils from "../global-utils"; |
||||
|
import { logNewUser, logUser } from './setups/sso'; |
||||
|
|
||||
|
let users = utils.loadEnv(); |
||||
|
|
||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { |
||||
|
await utils.startVaultwarden(browser, testInfo, { |
||||
|
SSO_ENABLED: true, |
||||
|
SSO_ONLY: true, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test.afterAll('Teardown', async ({}) => { |
||||
|
utils.stopVaultwarden(); |
||||
|
}); |
||||
|
|
||||
|
test('Create user3', async ({ page }) => { |
||||
|
await logNewUser(test, page, users.user3); |
||||
|
}); |
||||
|
|
||||
|
test('Invite users', async ({ page }) => { |
||||
|
await logNewUser(test, page, users.user1); |
||||
|
|
||||
|
await test.step('Create Org', async () => { |
||||
|
await page.getByRole('link', { name: 'New organisation' }).click(); |
||||
|
await page.getByLabel('Organisation name (required)').fill('Test'); |
||||
|
await page.getByRole('button', { name: 'Submit' }).click(); |
||||
|
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); |
||||
|
}); |
||||
|
|
||||
|
await test.step('Invite user2', async () => { |
||||
|
await page.getByRole('button', { name: 'Invite member' }).click(); |
||||
|
await page.getByLabel('Email (required)').fill(users.user2.email); |
||||
|
await page.getByRole('tab', { name: 'Collections' }).click(); |
||||
|
await page.getByLabel('Permission').selectOption('edit'); |
||||
|
await page.getByLabel('Select collections').click(); |
||||
|
await page.getByLabel('Options list').getByText('Default collection').click(); |
||||
|
await page.getByRole('button', { name: 'Save' }).click(); |
||||
|
await utils.checkNotification(page, 'User(s) invited'); |
||||
|
await expect(page.getByRole('row', { name: users.user2.email })).toHaveText(/Invited/); |
||||
|
}); |
||||
|
|
||||
|
await test.step('Invite user3', async () => { |
||||
|
await page.getByRole('button', { name: 'Invite member' }).click(); |
||||
|
await page.getByLabel('Email (required)').fill(users.user3.email); |
||||
|
await page.getByRole('tab', { name: 'Collections' }).click(); |
||||
|
await page.getByLabel('Permission').selectOption('edit'); |
||||
|
await page.getByLabel('Select collections').click(); |
||||
|
await page.getByLabel('Options list').getByText('Default collection').click(); |
||||
|
await page.getByRole('button', { name: 'Save' }).click(); |
||||
|
await utils.checkNotification(page, 'User(s) invited'); |
||||
|
await expect(page.getByRole('row', { name: users.user3.name })).toHaveText(/Needs confirmation/); |
||||
|
}); |
||||
|
|
||||
|
await test.step('Confirm existing user3', async () => { |
||||
|
await page.getByRole('row', { name: users.user3.name }).getByLabel('Options').click(); |
||||
|
await page.getByRole('menuitem', { name: 'Confirm' }).click(); |
||||
|
await page.getByRole('button', { name: 'Confirm' }).click(); |
||||
|
await utils.checkNotification(page, 'confirmed'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
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 page.getByLabel('Switch products').click(); |
||||
|
await page.getByRole('link', { name: ' Admin Console' }).click(); |
||||
|
await page.getByRole('link', { name: 'Members' }).click(); |
||||
|
|
||||
|
await expect(page.getByRole('row', { name: users.user2.name })).toHaveText(/Needs confirmation/); |
||||
|
|
||||
|
await test.step('Confirm user2', async () => { |
||||
|
await page.getByRole('row', { name: users.user2.name }).getByLabel('Options').click(); |
||||
|
await page.getByRole('menuitem', { name: 'Confirm' }).click(); |
||||
|
await page.getByRole('button', { name: 'Confirm' }).click(); |
||||
|
await utils.checkNotification(page, 'confirmed'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test('Organization is visible', async ({ page }) => { |
||||
|
await logUser(test, page, users.user2); |
||||
|
await page.getByLabel('vault: Test').click(); |
||||
|
await expect(page.getByLabel('Filter: Default collection')).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,648 @@ |
|||||
|
use chrono::Utc; |
||||
|
use derive_more::{AsRef, Deref, Display, From}; |
||||
|
use regex::Regex; |
||||
|
use std::borrow::Cow; |
||||
|
use std::time::Duration; |
||||
|
use url::Url; |
||||
|
|
||||
|
use mini_moka::sync::Cache; |
||||
|
use once_cell::sync::Lazy; |
||||
|
use openidconnect::core::{ |
||||
|
CoreClient, CoreIdTokenVerifier, CoreProviderMetadata, CoreResponseType, CoreUserInfoClaims, |
||||
|
}; |
||||
|
use openidconnect::reqwest; |
||||
|
use openidconnect::{ |
||||
|
AccessToken, AuthDisplay, AuthPrompt, AuthenticationFlow, AuthorizationCode, AuthorizationRequest, ClientId, |
||||
|
ClientSecret, CsrfToken, EndpointNotSet, EndpointSet, Nonce, OAuth2TokenResponse, PkceCodeChallenge, |
||||
|
PkceCodeVerifier, RefreshToken, ResponseType, Scope, |
||||
|
}; |
||||
|
|
||||
|
use crate::{ |
||||
|
api::ApiResult, |
||||
|
auth, |
||||
|
auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, |
||||
|
db::{ |
||||
|
models::{Device, SsoNonce, User}, |
||||
|
DbConn, |
||||
|
}, |
||||
|
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 CLIENT_CACHE_KEY: Lazy<String> = Lazy::new(|| "sso-client".to_string()); |
||||
|
static CLIENT_CACHE: Lazy<Cache<String, Client>> = Lazy::new(|| { |
||||
|
Cache::builder().max_capacity(1).time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())).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()); |
||||
|
|
||||
|
trait AuthorizationRequestExt<'a> { |
||||
|
fn add_extra_params<N: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(self, params: Vec<(N, V)>) -> Self; |
||||
|
} |
||||
|
|
||||
|
impl<'a, AD: AuthDisplay, P: AuthPrompt, RT: ResponseType> AuthorizationRequestExt<'a> |
||||
|
for AuthorizationRequest<'a, AD, P, RT> |
||||
|
{ |
||||
|
fn add_extra_params<N: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(mut self, params: Vec<(N, V)>) -> Self { |
||||
|
for (key, value) in params { |
||||
|
self = self.add_extra_param(key, value); |
||||
|
} |
||||
|
self |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#[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}")), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#[derive(Clone)] |
||||
|
struct Client { |
||||
|
http_client: reqwest::Client, |
||||
|
core_client: CoreClient<EndpointSet, EndpointNotSet, EndpointNotSet, EndpointNotSet, EndpointSet, EndpointSet>, |
||||
|
} |
||||
|
|
||||
|
impl Client { |
||||
|
// Call the OpenId discovery endpoint to retrieve configuration
|
||||
|
async fn _get_client() -> ApiResult<Self> { |
||||
|
let client_id = ClientId::new(CONFIG.sso_client_id()); |
||||
|
let client_secret = ClientSecret::new(CONFIG.sso_client_secret()); |
||||
|
|
||||
|
let issuer_url = CONFIG.sso_issuer_url()?; |
||||
|
|
||||
|
let http_client = match reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none()).build() { |
||||
|
Err(err) => err!(format!("Failed to build http client: {err}")), |
||||
|
Ok(client) => client, |
||||
|
}; |
||||
|
|
||||
|
let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, &http_client).await { |
||||
|
Err(err) => err!(format!("Failed to discover OpenID provider: {err}")), |
||||
|
Ok(metadata) => metadata, |
||||
|
}; |
||||
|
|
||||
|
let base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); |
||||
|
|
||||
|
let token_uri = match base_client.token_uri() { |
||||
|
Some(uri) => uri.clone(), |
||||
|
None => err!("Failed to discover token_url, cannot proceed"), |
||||
|
}; |
||||
|
|
||||
|
let user_info_url = match base_client.user_info_url() { |
||||
|
Some(url) => url.clone(), |
||||
|
None => err!("Failed to discover user_info url, cannot proceed"), |
||||
|
}; |
||||
|
|
||||
|
let core_client = base_client |
||||
|
.set_redirect_uri(CONFIG.sso_redirect_url()?) |
||||
|
.set_token_uri(token_uri) |
||||
|
.set_user_info_url(user_info_url); |
||||
|
|
||||
|
Ok(Client { |
||||
|
http_client, |
||||
|
core_client, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// Simple cache to prevent recalling the discovery endpoint each time
|
||||
|
async fn cached() -> ApiResult<Self> { |
||||
|
if CONFIG.sso_client_cache_expiration() > 0 { |
||||
|
match CLIENT_CACHE.get(&*CLIENT_CACHE_KEY) { |
||||
|
Some(client) => Ok(client), |
||||
|
None => Self::_get_client().await.inspect(|client| { |
||||
|
debug!("Inserting new client in cache"); |
||||
|
CLIENT_CACHE.insert(CLIENT_CACHE_KEY.clone(), client.clone()); |
||||
|
}), |
||||
|
} |
||||
|
} else { |
||||
|
Self::_get_client().await |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async fn user_info(&self, access_token: AccessToken) -> ApiResult<CoreUserInfoClaims> { |
||||
|
match self.core_client.user_info(access_token, None).request_async(&self.http_client).await { |
||||
|
Err(err) => err!(format!("Request to user_info endpoint failed: {err}")), |
||||
|
Ok(user_info) => Ok(user_info), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fn vw_id_token_verifier(&self) -> CoreIdTokenVerifier<'_> { |
||||
|
let mut verifier = self.core_client.id_token_verifier(); |
||||
|
if let Some(regex_str) = CONFIG.sso_audience_trusted() { |
||||
|
match Regex::new(®ex_str) { |
||||
|
Ok(regex) => { |
||||
|
verifier = verifier.set_other_audience_verifier_fn(move |aud| regex.is_match(aud)); |
||||
|
} |
||||
|
Err(err) => { |
||||
|
error!("Failed to parse SSO_AUDIENCE_TRUSTED={regex_str} regex: {err}"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
verifier |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
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
|
||||
|
// The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier).
|
||||
|
// 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 scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new); |
||||
|
let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes()); |
||||
|
|
||||
|
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 client = Client::cached().await?; |
||||
|
let mut auth_req = client |
||||
|
.core_client |
||||
|
.authorize_url( |
||||
|
AuthenticationFlow::<CoreResponseType>::AuthorizationCode, |
||||
|
|| CsrfToken::new(base64_state), |
||||
|
Nonce::new_random, |
||||
|
) |
||||
|
.add_scopes(scopes) |
||||
|
.add_extra_params(CONFIG.sso_authorize_extra_params_vec()?); |
||||
|
|
||||
|
let verifier = if CONFIG.sso_pkce() { |
||||
|
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); |
||||
|
auth_req = auth_req.set_pkce_challenge(pkce_challenge); |
||||
|
Some(pkce_verifier.secret().to_string()) |
||||
|
} else { |
||||
|
None |
||||
|
}; |
||||
|
|
||||
|
let (auth_url, _, nonce) = auth_req.url(); |
||||
|
|
||||
|
let sso_nonce = SsoNonce::new(state, nonce.secret().to_string(), verifier, redirect_uri); |
||||
|
sso_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> { |
||||
|
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 oidc_code = AuthorizationCode::new(code.to_string()); |
||||
|
let client = Client::cached().await?; |
||||
|
|
||||
|
let nonce = match SsoNonce::find(&state, conn).await { |
||||
|
None => err!(format!("Invalid state cannot retrieve nonce")), |
||||
|
Some(nonce) => nonce, |
||||
|
}; |
||||
|
|
||||
|
let mut exchange = client.core_client.exchange_code(oidc_code); |
||||
|
|
||||
|
if CONFIG.sso_pkce() { |
||||
|
match nonce.verifier { |
||||
|
None => err!(format!("Missing verifier in the DB nonce table")), |
||||
|
Some(secret) => exchange = exchange.set_pkce_verifier(PkceCodeVerifier::new(secret)), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
match exchange.request_async(&client.http_client).await { |
||||
|
Ok(token_response) => { |
||||
|
let user_info = client.user_info(token_response.access_token().to_owned()).await?; |
||||
|
let oidc_nonce = Nonce::new(nonce.nonce.clone()); |
||||
|
|
||||
|
let id_token = match token_response.extra_fields().id_token() { |
||||
|
None => err!("Token response did not contain an id_token"), |
||||
|
Some(token) => token, |
||||
|
}; |
||||
|
|
||||
|
if CONFIG.sso_debug_tokens() { |
||||
|
debug!("Id token: {}", id_token.to_string()); |
||||
|
debug!("Access token: {}", token_response.access_token().secret().to_string()); |
||||
|
debug!("Refresh token: {:?}", token_response.refresh_token().map(|t| t.secret().to_string())); |
||||
|
debug!("Expiration time: {:?}", token_response.expires_in()); |
||||
|
} |
||||
|
|
||||
|
let id_claims = match id_token.claims(&client.vw_id_token_verifier(), &oidc_nonce) { |
||||
|
Ok(claims) => claims, |
||||
|
Err(err) => { |
||||
|
if CONFIG.sso_client_cache_expiration() > 0 { |
||||
|
CLIENT_CACHE.invalidate(&*CLIENT_CACHE_KEY); |
||||
|
} |
||||
|
err!(format!("Could not read id_token claims, {err}")); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
let email = match id_claims.email() { |
||||
|
Some(email) => email.to_string(), |
||||
|
None => match user_info.email() { |
||||
|
None => err!("Neither id token nor userinfo contained an email"), |
||||
|
Some(email) => email.to_owned().to_string(), |
||||
|
}, |
||||
|
} |
||||
|
.to_lowercase(); |
||||
|
|
||||
|
let user_name = user_info.preferred_username().map(|un| un.to_string()); |
||||
|
|
||||
|
let refresh_token = token_response.refresh_token().map(|t| t.secret().to_string()); |
||||
|
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, |
||||
|
access_token: token_response.access_token().secret().to_string(), |
||||
|
expires_in: token_response.expires_in(), |
||||
|
identifier: identifier.clone(), |
||||
|
email: email.clone(), |
||||
|
email_verified: id_claims.email_verified(), |
||||
|
user_name: user_name.clone(), |
||||
|
}; |
||||
|
|
||||
|
AC_CACHE.insert(state.clone(), authenticated_user.clone()); |
||||
|
|
||||
|
Ok(UserInformation { |
||||
|
state, |
||||
|
identifier, |
||||
|
email, |
||||
|
email_verified: id_claims.email_verified(), |
||||
|
user_name, |
||||
|
}) |
||||
|
} |
||||
|
Err(err) => err!(format!("Failed to contact token endpoint: {err}")), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 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, |
||||
|
refresh_token: Option<String>, |
||||
|
access_token: &str, |
||||
|
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(), now); |
||||
|
|
||||
|
_create_auth_tokens(device, refresh_token, access_claims, access_token) |
||||
|
} else { |
||||
|
Ok(AuthTokens::new(device, user, AuthMethod::Sso)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fn _create_auth_tokens( |
||||
|
device: &Device, |
||||
|
refresh_token: Option<String>, |
||||
|
access_claims: auth::LoginJwtClaims, |
||||
|
access_token: &str, |
||||
|
) -> ApiResult<AuthTokens> { |
||||
|
let (nbf, exp, token) = if let Some(rt) = refresh_token.as_ref() { |
||||
|
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.to_string())) |
||||
|
} |
||||
|
Ok(refresh_payload) => { |
||||
|
debug!("Refresh_payload: {:?}", refresh_payload); |
||||
|
(refresh_payload.nbf(), refresh_payload.exp, TokenWrapper::Refresh(rt.to_string())) |
||||
|
} |
||||
|
} |
||||
|
} else { |
||||
|
debug!("No refresh_token present"); |
||||
|
(access_claims.nbf, access_claims.exp, TokenWrapper::Access(access_token.to_string())) |
||||
|
}; |
||||
|
|
||||
|
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, |
||||
|
refresh_claims: &auth::RefreshJwtClaims, |
||||
|
) -> ApiResult<AuthTokens> { |
||||
|
match &refresh_claims.token { |
||||
|
Some(TokenWrapper::Refresh(refresh_token)) => { |
||||
|
let rt = RefreshToken::new(refresh_token.to_string()); |
||||
|
|
||||
|
let client = Client::cached().await?; |
||||
|
|
||||
|
let token_response = |
||||
|
match client.core_client.exchange_refresh_token(&rt).request_async(&client.http_client).await { |
||||
|
Err(err) => err!(format!("Request to exchange_refresh_token endpoint failed: {:?}", err)), |
||||
|
Ok(token_response) => token_response, |
||||
|
}; |
||||
|
|
||||
|
// Use new refresh_token if returned
|
||||
|
let rolled_refresh_token = token_response |
||||
|
.refresh_token() |
||||
|
.map(|token| token.secret().to_string()) |
||||
|
.unwrap_or(refresh_token.to_string()); |
||||
|
|
||||
|
create_auth_tokens( |
||||
|
device, |
||||
|
user, |
||||
|
Some(rolled_refresh_token), |
||||
|
token_response.access_token().secret(), |
||||
|
token_response.expires_in(), |
||||
|
) |
||||
|
} |
||||
|
Some(TokenWrapper::Access(access_token)) => { |
||||
|
let now = Utc::now(); |
||||
|
let exp_limit = (now + *BW_EXPIRATION).timestamp(); |
||||
|
|
||||
|
if refresh_claims.exp < exp_limit { |
||||
|
err_silent!("Access token is close to expiration but we have no refresh token") |
||||
|
} |
||||
|
|
||||
|
let client = Client::cached().await?; |
||||
|
match client.user_info(AccessToken::new(access_token.to_string())).await { |
||||
|
Err(err) => { |
||||
|
err_silent!(format!("Failed to retrieve user info, token has probably been invalidated: {err}")) |
||||
|
} |
||||
|
Ok(_) => { |
||||
|
let access_claims = auth::LoginJwtClaims::new( |
||||
|
device, |
||||
|
user, |
||||
|
now.timestamp(), |
||||
|
refresh_claims.exp, |
||||
|
AuthMethod::Sso.scope_vec(), |
||||
|
now, |
||||
|
); |
||||
|
_create_auth_tokens(device, None, access_claims, access_token) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
None => err!("No token present while in SSO"), |
||||
|
} |
||||
|
} |
@ -0,0 +1,4 @@ |
|||||
|
Your Email Changed |
||||
|
<!----------------> |
||||
|
Your email was changed in your SSO Provider. Please update your email in Account Settings ({{url}}). |
||||
|
{{> email/email_footer_text }} |
@ -0,0 +1,11 @@ |
|||||
|
Your Email Changed |
||||
|
<!----------------> |
||||
|
{{> email/email_header }} |
||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> |
||||
|
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> |
||||
|
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center"> |
||||
|
Your email was changed in your SSO Provider. Please update your email in <a href="{{url}}/">Account Settings</a>. |
||||
|
</td> |
||||
|
</tr> |
||||
|
</table> |
||||
|
{{> email/email_footer }} |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue