Skip to content

Commit

Permalink
Allow SSO role mapping to add admin cookie
Browse files Browse the repository at this point in the history
Co-authored-by: Fabian Fischer <[email protected]>
  • Loading branch information
Timshel and nodomain committed Nov 26, 2024
1 parent 45a8bf3 commit 93cc80a
Show file tree
Hide file tree
Showing 14 changed files with 310 additions and 85 deletions.
6 changes: 6 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,12 @@
# SSO_MASTER_PASSWORD_POLICY='{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}'
## Use sso only for authentication not the session lifecycle
# SSO_AUTH_ONLY_NOT_SESSION=false
## Enable the mapping of roles (user/admin) from the access_token
# SSO_ROLES_ENABLED=false
## Missing/Invalid roles default to user
# SSO_ROLES_DEFAULT_TO_USER=true
## Id token path to read roles
# SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles
## Client cache for discovery endpoint. Duration in seconds (0 to disable).
# SSO_CLIENT_CACHE_EXPIRATION=0
## Log all the tokens, `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` need to be set
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,24 @@ Tagged version are based on Vaultwarden releases, Ex: `1.31.0-1` is the first re
\
See [changelog](CHANGELOG.md) for more details.

## Experimental Version
## Additionnal features

Made a version which allow to run the server without storing the master password (it's still required just not sent to the server).
This branch now contain additionnal features not added to the SSO [PR](https://github.com/dani-garcia/vaultwarden/pull/3899) since it would slow even more it's review.

### Role mapping

Allow to map roles from the Access token to users to grant access to `VaultWarden` `admin` console.
Support two roles: `admin` or `user`.

This feature is controlled by the following conf:

- `SSO_ROLES_ENABLED`: control if the mapping is done, default is `false`
- `SSO_ROLES_DEFAULT_TO_USER`: do not block login in case of missing or invalid roles, default is `true`.
- `SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles`: path to read roles in the Access token

### Experimental Version

Made a version which additionnaly allow to run the server without storing the master password (it's still required just not sent to the server).
It´s experimental, more information in [timshel/experimental](https://github.com/Timshel/vaultwarden/tree/experimental).

## Docker
Expand Down
12 changes: 11 additions & 1 deletion SSO.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ The following configurations are available
- `SSO_CLIENT_ID` : Client Id
- `SSO_CLIENT_SECRET` : Client Secret
- `SSO_MASTER_PASSWORD_POLICY`: Optional Master password policy
- `SSO_AUTH_ONLY_NOT_SESSION`: Enable to use SSO only for authentication not session lifecycle
- `SSO_AUTH_ONLY_NOT_SESSION`: Enable to use SSO only for authentication not session lifecycle.
- `SSO_ROLES_ENABLED`: control if the mapping is done, default is `false`
- `SSO_ROLES_DEFAULT_TO_USER`: do not block login in case of missing or invalid roles, default is `true`.
- `SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles`: path to read roles in the Id token
- `SSO_CLIENT_CACHE_EXPIRATION`: Cache calls to the discovery endpoint, duration in seconds, `0` to disable (default `0`);
- `SSO_DEBUG_TOKENS`: Log all tokens for easier debugging (default `false`, `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` need to be set)

Expand Down Expand Up @@ -220,6 +223,13 @@ Your configuration should look like this:
* `SSO_CLIENT_ID=${Application (client) ID}`
* `SSO_CLIENT_SECRET=${Secret Value}`

If you want to leverage role mapping you have to create app roles first as described here: https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-app-roles-in-apps.
Afterwards you can use these settings to derive the `admin` role from the ID token:

* `SSO_ROLES_ENABLED=true`
* `SSO_ROLES_DEFAULT_TO_USER=true`
* `SSO_ROLES_TOKEN_PATH=/roles

## Zitadel

To obtain a `refresh_token` to be able to extend session you'll need to add the `offline_access` scope.
Expand Down
10 changes: 10 additions & 0 deletions docker/keycloak/setup.dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
FROM registry.access.redhat.com/ubi9 AS ubi-micro-build

RUN dnf install -y wget && wget -O /root/jq https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64 && chmod +x /root/jq

FROM quay.io/keycloak/keycloak
COPY --from=ubi-micro-build /root/jq /usr/bin/jq

COPY keycloak_setup.sh /root/keycloak_setup.sh

ENTRYPOINT ["/root/keycloak_setup.sh"]
45 changes: 43 additions & 2 deletions playwright/compose/keycloak/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,57 @@ 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

## Delete default roles mapping
DEFAULT_ROLE_SCOPE_ID=$(kcadm.sh get -r "$TEST_REALM" client-scopes | jq -r '.[] | select(.name == "roles") | .id')
kcadm.sh delete -r "$TEST_REALM" "client-scopes/$DEFAULT_ROLE_SCOPE_ID"

## Create role mapping client scope
TEST_CLIENT_ROLES_SCOPE_ID=$(kcadm.sh create -r "$TEST_REALM" client-scopes -s name=roles -s protocol=openid-connect -i)
kcadm.sh create -r "$TEST_REALM" "client-scopes/$TEST_CLIENT_ROLES_SCOPE_ID/protocol-mappers/models" \
-s name=Roles \
-s protocol=openid-connect \
-s protocolMapper=oidc-usermodel-client-role-mapper \
-s consentRequired=false \
-s 'config."multivalued"=true' \
-s 'config."claim.name"=resource_access.${client_id}.roles' \
-s 'config."full.path"=false' \
-s 'config."id.token.claim"=true' \
-s 'config."access.token.claim"=false' \
-s 'config."userinfo.token.claim"=true'

TEST_CLIENT_ID=$(kcadm.sh create -r "$TEST_REALM" clients -s "name=VaultWarden" -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i)

## ADD Role mapping scope
kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID" --body "{\"optionalClientScopes\": [\"$TEST_CLIENT_ROLES_SCOPE_ID\"]}"
kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/optional-client-scopes/$TEST_CLIENT_ROLES_SCOPE_ID"

## CREATE TEST ROLES
kcadm.sh create -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/roles" -s name=admin -s 'description=Admin role'
kcadm.sh create -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/roles" -s name=user -s 'description=Admin role'

# To list roles : kcadm.sh get-roles -r "$TEST_REALM" --cid "$TEST_CLIENT_ID"

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
kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_ID/reset-password" -s type=password -s "value=$TEST_USER_PASSWORD" -n
kcadm.sh add-roles -r "$TEST_REALM" --uusername "$TEST_USER" --cid "$TEST_CLIENT_ID" --rolename admin


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
kcadm.sh add-roles -r "$TEST_REALM" --uusername "$TEST_USER2" --cid "$TEST_CLIENT_ID" --rolename user

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"

# TO DEBUG uncomment the following line to keep the setup container running
# sleep 3600
# THEN in another terminal:
# docker exec -it keycloakSetup-dev /bin/bash
# export PATH=$PATH:/opt/keycloak/bin
# kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli
# ENJOY
# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html
5 changes: 4 additions & 1 deletion playwright/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ services:
- SMTP_HOST
- SMTP_FROM
- SMTP_DEBUG
- SSO_FRONTEND
- SSO_ENABLED
- SSO_FRONTEND
- SSO_ONLY
- SSO_ROLES_DEFAULT_TO_USER
- SSO_ROLES_ENABLED
- SSO_SCOPES
restart: "no"
depends_on:
- VaultwardenPrebuild
Expand Down
57 changes: 57 additions & 0 deletions playwright/tests/sso_roles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { test, expect, type TestInfo } from '@playwright/test';

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,
SSO_ROLES_ENABLED: true,
SSO_ROLES_DEFAULT_TO_USER: false,
SSO_SCOPES: "email profile roles",
SSO_FRONTEND: "override",
});
});

test.afterAll('Teardown', async ({}) => {
utils.stopVaultwarden();
});

test('admin have access to vault/admin page', async ({ page }) => {
await logNewUser(test, page, users.user1, { override: true });

await page.goto('/admin');

await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();
});

test('user have access to vault', async ({ page }) => {
await logNewUser(test, page, users.user2, { override: true });

await page.goto('/admin');

await expect(page.getByRole('heading', { name: 'You do not have access' })).toBeVisible();
});

test('No role cannot log', async ({ page }) => {

await test.step('Landing page', async () => {
await page.goto('/');
await page.getByRole('button', { name: 'Log in'}).click();
});

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('Auth failed', async () => {
await expect(page).toHaveTitle('Vaultwarden Web');
await expect(page.getByTestId("toast-message")).toHaveText(/Invalid user role/);
});
});
46 changes: 25 additions & 21 deletions src/api/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use crate::{
core::{log_event, two_factor},
unregister_push_device, ApiResult, EmptyResult, JsonResult, Notify,
},
auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp, Secure},
auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp},
config::ConfigBuilder,
db::{backup_database, get_sql_server_version, models::*, DbConn, DbConnType},
error::{Error, MapResult},
Expand All @@ -31,8 +31,12 @@ use crate::{
CONFIG, VERSION,
};

#[allow(clippy::nonminimal_bool)]
pub fn routes() -> Vec<Route> {
if !CONFIG.disable_admin_token() && !CONFIG.is_admin_token_set() {
if !CONFIG.disable_admin_token()
&& !CONFIG.is_admin_token_set()
&& !(CONFIG.sso_enabled() && CONFIG.sso_roles_enabled())
{
return routes![admin_disabled];
}

Expand Down Expand Up @@ -91,15 +95,15 @@ fn admin_disabled() -> &'static str {
"The admin panel is disabled, please configure the 'ADMIN_TOKEN' variable to enable it"
}

const COOKIE_NAME: &str = "VW_ADMIN";
pub const COOKIE_NAME: &str = "VW_ADMIN";
const ADMIN_PATH: &str = "/admin";
const DT_FMT: &str = "%Y-%m-%d %H:%M:%S %Z";

const BASE_TEMPLATE: &str = "admin/base";

const ACTING_ADMIN_USER: &str = "vaultwarden-admin-00000-000000000000";

fn admin_path() -> String {
pub fn admin_path() -> String {
format!("{}{}", CONFIG.domain_path(), ADMIN_PATH)
}

Expand Down Expand Up @@ -154,6 +158,7 @@ fn render_admin_login(msg: Option<&str>, redirect: Option<String>) -> ApiResult<
let json = json!({
"page_content": "admin/login",
"error": msg,
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_roles_enabled(),
"redirect": redirect,
"urlpath": CONFIG.domain_path()
});
Expand All @@ -169,13 +174,21 @@ struct LoginForm {
redirect: Option<String>,
}

pub fn create_admin_cookie<'a>() -> Cookie<'a> {
let claims = generate_admin_claims();
let jwt = encode_jwt(&claims);

Cookie::build((COOKIE_NAME, jwt))
.path(admin_path())
.max_age(time::Duration::minutes(CONFIG.admin_session_lifetime()))
.same_site(SameSite::Strict)
.http_only(true)
.secure(CONFIG.is_https())
.into()
}

#[post("/", data = "<data>")]
fn post_admin_login(
data: Form<LoginForm>,
cookies: &CookieJar<'_>,
ip: ClientIp,
secure: Secure,
) -> Result<Redirect, AdminResponse> {
fn post_admin_login(data: Form<LoginForm>, cookies: &CookieJar<'_>, ip: ClientIp) -> Result<Redirect, AdminResponse> {
let data = data.into_inner();
let redirect = data.redirect;

Expand All @@ -192,17 +205,7 @@ fn post_admin_login(
Err(AdminResponse::Unauthorized(render_admin_login(Some("Invalid admin token, please try again."), redirect)))
} else {
// If the token received is valid, generate JWT and save it as a cookie
let claims = generate_admin_claims();
let jwt = encode_jwt(&claims);

let cookie = Cookie::build((COOKIE_NAME, jwt))
.path(admin_path())
.max_age(time::Duration::minutes(CONFIG.admin_session_lifetime()))
.same_site(SameSite::Strict)
.http_only(true)
.secure(secure.https);

cookies.add(cookie);
cookies.add(create_admin_cookie());
if let Some(redirect) = redirect {
Ok(Redirect::to(format!("{}{}", admin_path(), redirect)))
} else {
Expand Down Expand Up @@ -258,6 +261,7 @@ fn render_admin_page() -> ApiResult<Html<String>> {
let settings_json = json!({
"config": CONFIG.prepare_json(),
"can_backup": *CAN_BACKUP,
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_roles_enabled(),
});
let text = AdminTemplateData::new("admin/settings", settings_json).render()?;
Ok(Html(text))
Expand Down
25 changes: 21 additions & 4 deletions src/api/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use chrono::{NaiveDateTime, Utc};
use num_traits::FromPrimitive;
use rocket::{
form::{Form, FromForm},
http::Status,
http::{CookieJar, Status},
response::Redirect,
serde::json::Json,
Route,
Expand All @@ -11,6 +11,7 @@ use serde_json::Value;

use crate::{
api::{
admin,
core::{
accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade},
log_user_event,
Expand All @@ -31,7 +32,12 @@ pub fn routes() -> Vec<Route> {
}

#[post("/connect/token", data = "<data>")]
async fn login(data: Form<ConnectData>, client_header: ClientHeaders, mut conn: DbConn) -> JsonResult {
async fn login(
data: Form<ConnectData>,
client_header: ClientHeaders,
cookies: &CookieJar<'_>,
mut conn: DbConn,
) -> JsonResult {
let data: ConnectData = data.into_inner();

let mut user_uuid: Option<String> = None;
Expand Down Expand Up @@ -73,7 +79,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, mut conn:
_check_is_some(&data.device_name, "device_name cannot be blank")?;
_check_is_some(&data.device_type, "device_type cannot be blank")?;

_sso_login(data, &mut user_uuid, &mut conn, &client_header.ip).await
_sso_login(data, &mut user_uuid, &mut conn, cookies, &client_header.ip).await
}
"authorization_code" => err!("SSO sign-in is not available"),
t => err!("Invalid type", t),
Expand Down Expand Up @@ -145,7 +151,13 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult {
}

// After exchanging the code we need to check first if 2FA is needed before continuing
async fn _sso_login(data: ConnectData, user_uuid: &mut Option<String>, conn: &mut DbConn, ip: &ClientIp) -> JsonResult {
async fn _sso_login(
data: ConnectData,
user_uuid: &mut Option<String>,
conn: &mut DbConn,
cookies: &CookieJar<'_>,
ip: &ClientIp,
) -> JsonResult {
AuthMethod::Sso.check_scope(data.scope.as_ref())?;

// Ratelimit the login
Expand Down Expand Up @@ -246,6 +258,11 @@ async fn _sso_login(data: ConnectData, user_uuid: &mut Option<String>, conn: &mu
// Set the user_uuid here to be passed back used for event logging.
*user_uuid = Some(user.uuid.clone());

if auth_user.is_admin() {
info!("User {} logged with admin cookie", user.email);
cookies.add(admin::create_admin_cookie());
}

let auth_tokens = sso::create_auth_tokens(
&device,
&user,
Expand Down
Loading

0 comments on commit 93cc80a

Please sign in to comment.