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 Mar 12, 2024
1 parent 1b543fd commit 61729f6
Show file tree
Hide file tree
Showing 11 changed files with 240 additions and 49 deletions.
12 changes: 9 additions & 3 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -447,11 +447,17 @@
# SSO_CLIENT_SECRET=AAAAAAAAAAAAAAAAAAAAAAAA
## Optional Master password policy (minComplexity=[0-4])
# 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
## Use sso only for authentication not the session lifecycle
# SSO_AUTH_ONLY_NOT_SESSION=false
#### Client cache for discovery endpoint. Duration in seconds (0 to disable).
## 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
### Debug only, log all the tokens
## Debug only, log all the tokens
# SSO_DEBUG_TOKENS=false

########################
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ Based on [Timshel/sso-support](https://github.com/Timshel/vaultwarden/tree/sso-s

:warning: Branch will be rebased and forced-pushed from time to time. :warning:

## Additionnal features

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

## Docker

Change the docker files to package both front-end from [Timshel/oidc_web_builds](https://github.com/Timshel/oidc_web_builds/releases).
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`)

Expand Down Expand Up @@ -180,6 +183,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
8 changes: 4 additions & 4 deletions docker/keycloak/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ services:
- ./keycloak_setup.sh:/opt/script/keycloak_setup.sh
keycloakSetup:
container_name: keycloakSetup-${ENV:-dev}
image: quay.io/keycloak/keycloak
image: keycloaksetup
network_mode: "host"
build:
context: .
dockerfile: setup.dockerfile
depends_on:
- keycloak
restart: "no"
env_file: ${ENV}.env
entrypoint: [ "bash", "-c", "/opt/script/keycloak_setup.sh"]
volumes:
- ${KC_SETUP_PATH:-.}/keycloak_setup.sh:/opt/script/keycloak_setup.sh
VaultWarden:
container_name: oidc-vaultwarden-${ENV:-dev}
image: oidc-vaultwarden
Expand Down
53 changes: 47 additions & 6 deletions docker/keycloak/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 @@ -21,12 +21,53 @@ while true; do
done

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 "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 "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
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"]
34 changes: 21 additions & 13 deletions src/api/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,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 @@ -89,15 +93,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 @@ -152,6 +156,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 @@ -167,6 +172,18 @@ 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)
.into()
}

#[post("/", data = "<data>")]
fn post_admin_login(data: Form<LoginForm>, cookies: &CookieJar<'_>, ip: ClientIp) -> Result<Redirect, AdminResponse> {
let data = data.into_inner();
Expand All @@ -185,16 +202,7 @@ fn post_admin_login(data: Form<LoginForm>, cookies: &CookieJar<'_>, ip: ClientIp
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);

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 @@ -252,6 +264,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
6 changes: 6 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,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
Expand Down
Loading

0 comments on commit 61729f6

Please sign in to comment.