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 Jul 25, 2024
1 parent 30f3a9d commit f25f45c
Show file tree
Hide file tree
Showing 11 changed files with 243 additions and 77 deletions.
6 changes: 6 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,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 @@ -25,7 +25,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 @@ -196,6 +199,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"]
53 changes: 47 additions & 6 deletions playwright/compose/keycloak_setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ CANARY=/tmp/keycloak_setup_done

if [ -f $CANARY ]
then
echo "Setup should already be done. Will not run."
exit 0
echo "Setup should already be done. Will not run."
exit 0
fi

while true; do
sleep 5
sleep 5
kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli
EC=$?
if [ $EC -eq 0 ]; then
Expand All @@ -23,12 +23,53 @@ done
set -e

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_USER_2_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER_2" -s "firstName=$TEST_USER_2" -s "lastName=$TEST_USER_2" -s "email=$TEST_USER_2_MAIL" -s emailVerified=true -s enabled=true -i)
kcadm.sh update users/$TEST_USER_2_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER_2_PASSWORD" -n
kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_2_ID/reset-password" -s type=password -s "value=$TEST_USER_2_PASSWORD" -n
kcadm.sh add-roles -r "$TEST_REALM" --uusername "$TEST_USER_2" --cid "$TEST_CLIENT_ID" --rolename user

touch $CANARY

# 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
39 changes: 23 additions & 16 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 @@ -30,8 +30,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 @@ -90,15 +94,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 @@ -153,6 +157,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 @@ -168,12 +173,24 @@ 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(rocket::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> {
let data = data.into_inner();
let redirect = data.redirect;
Expand All @@ -191,17 +208,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(rocket::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
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 @@ -152,7 +158,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 @@ -248,6 +260,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
29 changes: 0 additions & 29 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -926,35 +926,6 @@ impl<'r> FromRequest<'r> for ClientIp {
}
}

pub struct Secure {
pub https: bool,
}

#[rocket::async_trait]
impl<'r> FromRequest<'r> for Secure {
type Error = ();

async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = request.headers();

// Try to guess from the headers
let protocol = match headers.get_one("X-Forwarded-Proto") {
Some(proto) => proto,
None => {
if env::var("ROCKET_TLS").is_ok() {
"https"
} else {
"http"
}
}
};

Outcome::Success(Secure {
https: protocol == "https",
})
}
}

pub struct WsAccessTokenHeader {
pub access_token: Option<String>,
}
Expand Down
11 changes: 11 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,12 @@ make_config! {
sso_master_password_policy: String, true, option;
/// Use sso only for auth not the session lifecycle
sso_auth_only_not_session: bool, true, def, false;
/// Enable the mapping of roles (user/admin) from the access_token
sso_roles_enabled: bool, false, def, false;
/// Missing invalid roles default to user
sso_roles_default_to_user: bool, false, def, true;
/// Id token path to read roles
sso_roles_token_path: String, false, auto, |c| format!("/resource_access/{}/roles", c.sso_client_id);
/// Client cache for discovery endpoint. Duration in seconds (0 or less to disable).
sso_client_cache_expiration: u64, true, def, 0;
/// Log all tokens, `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
Expand Down Expand Up @@ -1344,6 +1350,11 @@ impl Config {
token.is_some() && !token.unwrap().trim().is_empty()
}

/// Tests whether the domain contain HTTPS.
pub fn is_https(&self) -> bool {
self.domain().starts_with("https")
}

pub fn render_template<T: serde::ser::Serialize>(
&self,
name: &str,
Expand Down
Loading

0 comments on commit f25f45c

Please sign in to comment.