diff --git a/.env.template b/.env.template index 5d24689a29..21e0e6e585 100644 --- a/.env.template +++ b/.env.template @@ -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 diff --git a/README.md b/README.md index 80fd8bd6fd..0438338d94 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SSO.md b/SSO.md index 7f7b692e3c..9e48fcecaa 100644 --- a/SSO.md +++ b/SSO.md @@ -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) @@ -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. diff --git a/docker/keycloak/setup.dockerfile b/docker/keycloak/setup.dockerfile new file mode 100644 index 0000000000..f4cb507403 --- /dev/null +++ b/docker/keycloak/setup.dockerfile @@ -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"] diff --git a/playwright/compose/keycloak/setup.sh b/playwright/compose/keycloak/setup.sh index 36597b1d21..e94d954e74 100755 --- a/playwright/compose/keycloak/setup.sh +++ b/playwright/compose/keycloak/setup.sh @@ -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 diff --git a/playwright/docker-compose.yml b/playwright/docker-compose.yml index 7a2081f475..e30a2a91a6 100644 --- a/playwright/docker-compose.yml +++ b/playwright/docker-compose.yml @@ -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 diff --git a/playwright/tests/sso_roles.spec.ts b/playwright/tests/sso_roles.spec.ts new file mode 100644 index 0000000000..98d7b59abc --- /dev/null +++ b/playwright/tests/sso_roles.spec.ts @@ -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/); + }); +}); diff --git a/src/api/admin.rs b/src/api/admin.rs index 84e716ecf9..fb59c3efe7 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -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}, @@ -31,8 +31,12 @@ use crate::{ CONFIG, VERSION, }; +#[allow(clippy::nonminimal_bool)] pub fn routes() -> Vec { - 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]; } @@ -91,7 +95,7 @@ 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"; @@ -99,7 +103,7 @@ 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) } @@ -154,6 +158,7 @@ fn render_admin_login(msg: Option<&str>, redirect: Option) -> 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() }); @@ -169,13 +174,21 @@ struct LoginForm { redirect: Option, } +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 = "")] -fn post_admin_login( - data: Form, - cookies: &CookieJar<'_>, - ip: ClientIp, - secure: Secure, -) -> Result { +fn post_admin_login(data: Form, cookies: &CookieJar<'_>, ip: ClientIp) -> Result { let data = data.into_inner(); let redirect = data.redirect; @@ -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 { @@ -258,6 +261,7 @@ fn render_admin_page() -> ApiResult> { 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)) diff --git a/src/api/identity.rs b/src/api/identity.rs index e767201866..74ecc65740 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -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, @@ -11,6 +11,7 @@ use serde_json::Value; use crate::{ api::{ + admin, core::{ accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade}, log_user_event, @@ -31,7 +32,12 @@ pub fn routes() -> Vec { } #[post("/connect/token", data = "")] -async fn login(data: Form, client_header: ClientHeaders, mut conn: DbConn) -> JsonResult { +async fn login( + data: Form, + client_header: ClientHeaders, + cookies: &CookieJar<'_>, + mut conn: DbConn, +) -> JsonResult { let data: ConnectData = data.into_inner(); let mut user_uuid: Option = None; @@ -73,7 +79,7 @@ async fn login(data: Form, 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), @@ -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, conn: &mut DbConn, ip: &ClientIp) -> JsonResult { +async fn _sso_login( + data: ConnectData, + user_uuid: &mut Option, + conn: &mut DbConn, + cookies: &CookieJar<'_>, + ip: &ClientIp, +) -> JsonResult { AuthMethod::Sso.check_scope(data.scope.as_ref())?; // Ratelimit the login @@ -246,6 +258,11 @@ async fn _sso_login(data: ConnectData, user_uuid: &mut Option, 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, diff --git a/src/auth.rs b/src/auth.rs index 437d63312c..326dcb543d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -923,35 +923,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 { - 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, } diff --git a/src/config.rs b/src/config.rs index 9162f2b7ab..5824574a1b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -670,6 +670,12 @@ make_config! { sso_master_password_policy: String, true, option; /// Use sso only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days) sso_auth_only_not_session: bool, true, def, false; + /// Roles mapping |> 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). More details: https://github.com/dani-garcia/vaultwarden/blob/sso-support/SSO.md#client-cache sso_client_cache_expiration: u64, true, def, 0; /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required @@ -1375,6 +1381,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(&self, name: &str, data: &T) -> Result { if self.reload_templates() { warn!("RELOADING TEMPLATES"); diff --git a/src/sso.rs b/src/sso.rs index 2406ed0c48..d25ae77fdd 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -1,5 +1,6 @@ use chrono::Utc; use regex::Regex; +use serde::de::DeserializeOwned; use std::borrow::Cow; use std::time::Duration; use url::Url; @@ -21,7 +22,7 @@ use crate::{ auth, auth::{AuthMethod, AuthMethodScope, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, db::{ - models::{Device, SsoNonce, User}, + models::{Device, EventType, SsoNonce, User}, DbConn, }, CONFIG, @@ -125,15 +126,18 @@ impl BasicTokenClaims { } } -fn decode_token_claims(token_name: &str, token: &str) -> ApiResult { +// IdToken validation is handled by IdToken.claims +// This is only used to retrive additionnal claims which are configurable +// Or to try to parse access_token and refresh_tken as JWT to find exp +fn insecure_decode(token_name: &str, token: &str) -> ApiResult { 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) { + 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}")), + Err(err) => err_silent!(format!("Failed to decode {token_name}: {err}")), } } @@ -252,6 +256,18 @@ pub async fn authorize_url(state: String, client_id: &str, raw_redirect_uri: &st Ok(auth_url) } +#[derive(Debug)] +struct AdditionnalClaims { + role: Option, +} + +#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum UserRole { + Admin, + User, +} + #[derive(Clone, Debug)] pub struct AuthenticatedUser { pub refresh_token: Option, @@ -261,6 +277,13 @@ pub struct AuthenticatedUser { pub email: String, pub email_verified: Option, pub user_name: Option, + pub role: Option, +} + +impl AuthenticatedUser { + pub fn is_admin(&self) -> bool { + self.role.as_ref().is_some_and(|x| x == &UserRole::Admin) + } } #[derive(Clone, Debug)] @@ -272,6 +295,53 @@ pub struct UserInformation { pub user_name: Option, } +// Errors are logged but will return None +fn roles(email: &str, token: &serde_json::Value) -> Option { + if let Some(json_roles) = token.pointer(&CONFIG.sso_roles_token_path()) { + match serde_json::from_value::>(json_roles.clone()) { + Ok(mut roles) => { + roles.sort(); + roles.into_iter().next() + } + Err(err) => { + debug!("Failed to parse user ({email}) roles: {err}"); + None + } + } + } else { + debug!("No roles in {email} id_token at {}", &CONFIG.sso_roles_token_path()); + None + } +} + +// Trying to conditionnally read additionnal configurable claims using openidconnect appear nightmarish +// So we just decode the token again as a JsValue +fn additional_claims(email: &str, token: &str) -> ApiResult { + let mut role = None; + + if CONFIG.sso_roles_enabled() { + match insecure_decode::("id_token", token) { + Err(err) => err!(format!("Could not decode access token: {:?}", err)), + Ok(claims) => { + role = roles(email, &claims); + if !CONFIG.sso_roles_default_to_user() && role.is_none() { + info!("User {email} failed to login due to missing/invalid role"); + err!( + "Invalid user role. Contact your administrator", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + } + } + } + + Ok(AdditionnalClaims { + role, + }) +} + async fn decode_code_claims(code: &str, conn: &mut DbConn) -> ApiResult<(String, String)> { match auth::decode_jwt::(code, SSO_JWT_ISSUER.to_string()) { Ok(code_claims) => match code_claims.code { @@ -371,8 +441,9 @@ pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult ApiResult("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"), @@ -452,7 +524,7 @@ fn _create_auth_tokens( access_token: &str, ) -> ApiResult { let (nbf, exp, token) = if let Some(rt) = refresh_token.as_ref() { - match decode_token_claims("refresh_token", rt) { + match insecure_decode::("refresh_token", rt) { Err(_) => { let time_now = Utc::now(); let exp = (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp(); diff --git a/src/static/templates/admin/login.hbs b/src/static/templates/admin/login.hbs index 3ea94aecc7..d04f446923 100644 --- a/src/static/templates/admin/login.hbs +++ b/src/static/templates/admin/login.hbs @@ -8,17 +8,23 @@ {{/if}}
-
-
Authentication key needed to continue
- Please provide it below: + {{#if sso_only}} +
+
You do not have access to the admin panel (or the admin session expired and you need to log again)
+
+ {{else}} +
+
Authentication key needed to continue
+ Please provide it below: -
- - {{#if redirect}} - - {{/if}} - -
-
+
+ + {{#if redirect}} + + {{/if}} + +
+
+ {{/if}}
diff --git a/src/static/templates/admin/settings.hbs b/src/static/templates/admin/settings.hbs index fb066cb44c..9d02323be8 100644 --- a/src/static/templates/admin/settings.hbs +++ b/src/static/templates/admin/settings.hbs @@ -1,10 +1,12 @@
-
- - You are using a plain text `ADMIN_TOKEN` which is insecure.
- Please generate a secure Argon2 PHC string by using `vaultwarden hash` or `argon2`.
- See: Enabling admin page - Secure the `ADMIN_TOKEN` -
+ {{#unless page_data.sso_only}} +
+ + You are using a plain text `ADMIN_TOKEN` which is insecure.
+ Please generate a secure Argon2 PHC string by using `vaultwarden hash` or `argon2`.
+ See: Enabling admin page - Secure the `ADMIN_TOKEN` +
+ {{/unless}}
Configuration