From 25ab8a14542c18844d2b57bdc7cbbfb1fc44f8d5 Mon Sep 17 00:00:00 2001 From: Timshel Date: Wed, 3 Jan 2024 17:24:57 +0100 Subject: [PATCH] Allow SSO role mapping to add admin cookie Co-authored-by: Fabian Fischer --- .env.template | 9 ++ Cargo.lock | 1 + Cargo.toml | 1 + playwright/compose/keycloak/Dockerfile | 2 +- playwright/compose/keycloak/setup.sh | 45 ++++++- playwright/docker-compose.yml | 5 +- playwright/test.env | 2 +- playwright/tests/sso_roles.spec.ts | 56 +++++++++ src/api/admin.rs | 43 ++++--- src/api/identity.rs | 56 +++++---- src/auth.rs | 8 +- src/config.rs | 6 + src/sso.rs | 157 +++++++++++++++++++++--- src/sso_client.rs | 78 +++++++++--- src/static/templates/admin/login.hbs | 28 +++-- src/static/templates/admin/settings.hbs | 14 ++- 16 files changed, 417 insertions(+), 94 deletions(-) create mode 100644 playwright/tests/sso_roles.spec.ts diff --git a/.env.template b/.env.template index e31e2f34..b90a280e 100644 --- a/.env.template +++ b/.env.template @@ -512,6 +512,15 @@ ## Log all the tokens, LOG_LEVEL=debug is required # SSO_DEBUG_TOKENS=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 + ######################## ### MFA/2FA settings ### ######################## diff --git a/Cargo.lock b/Cargo.lock index b9dbf69d..c1b5469d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5722,6 +5722,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_with", "subtle", "svg-hush", "syslog", diff --git a/Cargo.toml b/Cargo.toml index f5df3a49..f9ffbdab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,7 @@ tokio-util = { version = "0.7.16", features = ["compat"]} # A generic serialization/deserialization framework serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" +serde_with = "3.14.0" # A safe, extensible ORM and Query builder diesel = { version = "2.2.12", features = ["chrono", "r2d2", "numeric"] } diff --git a/playwright/compose/keycloak/Dockerfile b/playwright/compose/keycloak/Dockerfile index 35888950..63dd2547 100644 --- a/playwright/compose/keycloak/Dockerfile +++ b/playwright/compose/keycloak/Dockerfile @@ -21,7 +21,7 @@ ARG KEYCLOAK_VERSION SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN apt-get update \ - && apt-get install -y ca-certificates curl wget \ + && apt-get install -y ca-certificates curl wget jq \ && rm -rf /var/lib/apt/lists/* ARG JAVA_URL diff --git a/playwright/compose/keycloak/setup.sh b/playwright/compose/keycloak/setup.sh index 36597b1d..25dd251b 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/*\", \"http://localhost:$ROCKET_PORT/*\"]" -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 3e56477c..63a72dfe 100644 --- a/playwright/docker-compose.yml +++ b/playwright/docker-compose.yml @@ -30,9 +30,12 @@ services: - SMTP_FROM - SMTP_DEBUG - SSO_DEBUG_TOKENS - - 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/test.env b/playwright/test.env index 89dd6651..300e0d55 100644 --- a/playwright/test.env +++ b/playwright/test.env @@ -52,7 +52,7 @@ 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} +DOMAIN=http://localhost:${ROCKET_PORT} LOG_LEVEL=info,oidcwarden::sso=debug LOGIN_RATELIMIT_MAX_BURST=100 diff --git a/playwright/tests/sso_roles.spec.ts b/playwright/tests/sso_roles.spec.ts new file mode 100644 index 00000000..cc9cff30 --- /dev/null +++ b/playwright/tests/sso_roles.spec.ts @@ -0,0 +1,56 @@ +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.startVault(browser, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: true, + SSO_ROLES_ENABLED: true, + SSO_ROLES_DEFAULT_TO_USER: false, + SSO_SCOPES: "email profile roles", + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); +}); + +test('admin have access to vault/admin page', async ({ page }) => { + await logNewUser(test, page, users.user1); + + 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); + + 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 utils.cleanLanding(page); + await page.getByLabel(/Email address/).fill(users.user3.email); + await page.getByRole('button', { name: /Use 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(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 utils.checkNotification(page, 'Invalid user role'); + }); +}); diff --git a/src/api/admin.rs b/src/api/admin.rs index d52e24ef..12b0b0a6 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -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]; } @@ -157,6 +161,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() }); @@ -172,6 +177,24 @@ struct LoginForm { redirect: Option, } +pub fn add_admin_cookie(cookies: &CookieJar<'_>, is_secure: bool) { + 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(is_secure); + + cookies.add(cookie); +} + +pub fn remove_admin_cookie(cookies: &CookieJar<'_>) { + cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path())); +} + #[post("/", format = "application/x-www-form-urlencoded", data = "")] fn post_admin_login( data: Form, @@ -195,17 +218,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); + add_admin_cookie(cookies, secure.https); if let Some(redirect) = redirect { Ok(Redirect::to(format!("{}{redirect}", admin_path()))) } else { @@ -263,6 +276,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)) @@ -331,7 +345,7 @@ async fn test_smtp(data: Json, _token: AdminToken) -> EmptyResult { #[get("/logout")] fn logout(cookies: &CookieJar<'_>) -> Redirect { - cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path())); + remove_admin_cookie(cookies); Redirect::to(admin_path()) } @@ -839,8 +853,7 @@ impl<'r> FromRequest<'r> for AdminToken { }; if decode_admin(access_token).is_err() { - // Remove admin cookie - cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path())); + remove_admin_cookie(cookies); error!("Invalid or expired admin JWT. IP: {}.", &ip.ip); return Outcome::Error((Status::Unauthorized, "Session expired")); } diff --git a/src/api/identity.rs b/src/api/identity.rs index ba22104e..d008adb7 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, @@ -12,6 +12,7 @@ use serde_json::Value; use crate::api::core::two_factor::webauthn::Webauthn2FaConfig; use crate::{ api::{ + admin, core::{ accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade}, log_user_event, @@ -22,7 +23,7 @@ use crate::{ ApiResult, EmptyResult, JsonResult, }, auth, - auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion}, + auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure}, db::{models::*, DbConn}, error::MapResult, mail, sso, @@ -50,6 +51,8 @@ async fn login( client_header: ClientHeaders, client_version: Option, webauthn: Webauthn2FaConfig<'_>, + cookies: &CookieJar<'_>, + secure: Secure, mut conn: DbConn, ) -> JsonResult { let data: ConnectData = data.into_inner(); @@ -59,7 +62,7 @@ async fn login( let login_result = match data.grant_type.as_ref() { "refresh_token" => { _check_is_some(&data.refresh_token, "refresh_token cannot be blank")?; - _refresh_login(data, &mut conn, &client_header.ip).await + _refresh_login(data, &mut conn, cookies, &client_header.ip, secure).await } "password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"), "password" => { @@ -93,7 +96,8 @@ async fn login( _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_id, &mut conn, &client_header.ip, &client_version, webauthn).await + _sso_login(data, &mut user_id, &mut conn, cookies, &client_header.ip, secure, &client_version, webauthn) + .await } "authorization_code" => err!("SSO sign-in is not available"), t => err!("Invalid type", t), @@ -130,7 +134,13 @@ async fn login( } // Return Status::Unauthorized to trigger logout -async fn _refresh_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) -> JsonResult { +async fn _refresh_login( + data: ConnectData, + conn: &mut DbConn, + cookies: &CookieJar<'_>, + ip: &ClientIp, + secure: Secure, +) -> JsonResult { // Extract token let refresh_token = match data.refresh_token { Some(token) => token, @@ -147,10 +157,17 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) -> Err(err) => { err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code) } - Ok((mut device, auth_tokens)) => { + Ok((user, mut device, auth_tokens)) => { // Save to update `device.updated_at` to track usage and toggle new status device.save(conn).await?; + if auth_tokens.is_admin { + debug!("Refreshed {} admin cookie", user.email); + admin::add_admin_cookie(cookies, secure.https); + } else { + admin::remove_admin_cookie(cookies); + } + let result = json!({ "refresh_token": auth_tokens.refresh_token(), "access_token": auth_tokens.access_token(), @@ -165,11 +182,14 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) -> } // After exchanging the code we need to check first if 2FA is needed before continuing +#[allow(clippy::too_many_arguments)] async fn _sso_login( data: ConnectData, user_id: &mut Option, conn: &mut DbConn, + cookies: &CookieJar<'_>, ip: &ClientIp, + secure: Secure, client_version: &Option, webauthn: Webauthn2FaConfig<'_>, ) -> JsonResult { @@ -293,28 +313,16 @@ async fn _sso_login( } }; - // We passed 2FA get full user information - let auth_user = sso::redeem(&user_infos.state, conn).await?; - - if sso_user.is_none() { - let user_sso = SsoUser { - user_uuid: user.uuid.clone(), - identifier: user_infos.identifier, - }; - user_sso.save(conn).await?; - } + // We passed 2FA get auth tokens + let auth_tokens = sso::redeem(&user, &device, data.client_id, sso_user, &user_infos.state, conn).await?; // Set the user_uuid here to be passed back used for event logging. *user_id = Some(user.uuid.clone()); - let auth_tokens = sso::create_auth_tokens( - &device, - &user, - data.client_id, - auth_user.refresh_token, - auth_user.access_token, - auth_user.expires_in, - )?; + if auth_tokens.is_admin { + info!("User {} logged with admin cookie", user.email); + admin::add_admin_cookie(cookies, secure.https); + } authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await } diff --git a/src/auth.rs b/src/auth.rs index a4a0b22c..f62e8937 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1149,6 +1149,7 @@ pub struct RefreshJwtClaims { pub struct AuthTokens { pub refresh_claims: RefreshJwtClaims, pub access_claims: LoginJwtClaims, + pub is_admin: bool, } impl AuthTokens { @@ -1192,6 +1193,7 @@ impl AuthTokens { Self { refresh_claims, access_claims, + is_admin: false, } } } @@ -1201,7 +1203,7 @@ pub async fn refresh_tokens( refresh_token: &str, client_id: Option, conn: &mut DbConn, -) -> ApiResult<(Device, AuthTokens)> { +) -> ApiResult<(User, Device, AuthTokens)> { let refresh_claims = match decode_refresh(refresh_token) { Err(err) => { debug!("Failed to decode {} refresh_token: {refresh_token}", ip.ip); @@ -1229,7 +1231,7 @@ pub async fn refresh_tokens( AuthTokens::new(&device, &user, refresh_claims.sub, client_id) } AuthMethod::Sso if CONFIG.sso_enabled() => { - sso::exchange_refresh_token(&device, &user, client_id, refresh_claims).await? + sso::exchange_refresh_token(&user, &device, client_id, refresh_claims).await? } AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"), AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"), @@ -1237,5 +1239,5 @@ pub async fn refresh_tokens( _ => err!("Invalid auth method, cannot refresh token"), }; - Ok((device, auth_tokens)) + Ok((user, device, auth_tokens)) } diff --git a/src/config.rs b/src/config.rs index 545d7dce..ef6517f7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -716,6 +716,12 @@ make_config! { sso_client_cache_expiration: u64, true, def, 0; /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required sso_debug_tokens: bool, true, def, false; + /// Roles mapping |> Enable the mapping of roles (user/admin) from the access_token + sso_roles_enabled: bool, true, def, false; + /// Missing roles default to user |> If `false` user with no role won't be able to log + sso_roles_default_to_user: bool, true, def, true; + /// Path to read roles in IDToken or User Info + sso_roles_token_path: String, true, auto, |c| format!("/resource_access/{}/roles", c.sso_client_id); }, /// Yubikey settings diff --git a/src/sso.rs b/src/sso.rs index 8e746114..26a39413 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -1,6 +1,8 @@ use chrono::Utc; use derive_more::{AsRef, Deref, Display, From}; use regex::Regex; +use serde::de::DeserializeOwned; +use serde_with::{serde_as, DefaultOnError}; use std::time::Duration; use url::Url; @@ -12,10 +14,10 @@ use crate::{ auth, auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, db::{ - models::{Device, SsoNonce, User}, + models::{Device, EventType, SsoNonce, SsoUser, User}, DbConn, }, - sso_client::Client, + sso_client::{AllAdditionalClaims, Client}, CONFIG, }; @@ -139,15 +141,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}")), } } @@ -215,6 +220,28 @@ impl OIDCIdentifier { } } +#[derive(Debug)] +struct AdditionnalClaims { + role: Option, +} + +impl AdditionnalClaims { + pub fn is_admin(&self) -> bool { + self.role.as_ref().is_some_and(|x| x == &UserRole::Admin) + } +} + +#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum UserRole { + Admin, + User, +} + +#[serde_as] +#[derive(Deserialize)] +struct UserRoles(#[serde_as(as = "Vec")] Vec>); + #[derive(Clone, Debug)] pub struct AuthenticatedUser { pub refresh_token: Option, @@ -224,6 +251,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)] @@ -235,6 +269,44 @@ pub struct UserInformation { pub user_name: Option, } +// Errors are logged but will return None +// Return the top most defined Role (https://doc.rust-lang.org/std/cmp/trait.PartialOrd.html#derivable) +fn role_claim(email: &str, token: &serde_json::Value, source: &str) -> Option { + use crate::serde::Deserialize; + if let Some(json_roles) = token.pointer(&CONFIG.sso_roles_token_path()) { + match UserRoles::::deserialize(json_roles) { + Ok(UserRoles(mut roles)) => { + roles.sort(); + roles.into_iter().find(|r| r.is_some()).flatten() + } + Err(err) => { + debug!("Failed to parse {email} roles from {source}: {err}"); + None + } + } + } else { + debug!("No roles in {email} {source} at {}", &CONFIG.sso_roles_token_path()); + None + } +} + +// All claims are read as Value. +fn additional_claims(email: &str, sources: Vec<(&AllAdditionalClaims, &str)>) -> ApiResult { + let mut role: Option = None; + + if CONFIG.sso_roles_enabled() { + for (ac, source) in sources { + if CONFIG.sso_roles_enabled() { + role = role.or_else(|| role_claim(email, &ac.claims, source)) + } + } + } + + Ok(AdditionnalClaims { + role, + }) +} + async fn decode_code_claims(code: &str, conn: &mut DbConn) -> ApiResult<(OIDCCode, OIDCState)> { match auth::decode_jwt::(code, SSO_JWT_ISSUER.to_string()) { Ok(code_claims) => match code_claims.code { @@ -299,6 +371,21 @@ pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult ApiResult ApiResult ApiResult { +pub async fn redeem( + user: &User, + device: &Device, + client_id: Option, + sso_user: Option, + state: &OIDCState, + conn: &mut DbConn, +) -> ApiResult { 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) { + let auth_user = if let Some(au) = AC_CACHE.get(state) { AC_CACHE.invalidate(state); - Ok(au) + au } else { err!("Failed to retrieve user info from sso cache") + }; + + if sso_user.is_none() { + let user_sso = SsoUser { + user_uuid: user.uuid.clone(), + identifier: auth_user.identifier.clone(), + }; + user_sso.save(conn).await?; } + + let is_admin = auth_user.is_admin(); + create_auth_tokens( + device, + user, + client_id, + auth_user.refresh_token, + auth_user.access_token, + auth_user.expires_in, + is_admin, + ) } // We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front). @@ -352,11 +466,12 @@ pub fn create_auth_tokens( refresh_token: Option, access_token: String, expires_in: Option, + is_admin: bool, ) -> ApiResult { 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) { + let (ap_nbf, ap_exp) = match (insecure_decode::("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"), @@ -365,7 +480,7 @@ pub fn create_auth_tokens( let access_claims = auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now); - _create_auth_tokens(device, refresh_token, access_claims, access_token) + _create_auth_tokens(device, refresh_token, access_claims, access_token, is_admin) } else { Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id)) } @@ -376,9 +491,10 @@ fn _create_auth_tokens( refresh_token: Option, access_claims: auth::LoginJwtClaims, access_token: String, + is_admin: bool, ) -> ApiResult { let (nbf, exp, token) = if let Some(rt) = refresh_token { - 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(); @@ -407,6 +523,7 @@ fn _create_auth_tokens( Ok(AuthTokens { refresh_claims, access_claims, + is_admin, }) } @@ -414,25 +531,35 @@ fn _create_auth_tokens( // - 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, + device: &Device, client_id: Option, refresh_claims: auth::RefreshJwtClaims, ) -> ApiResult { let exp = refresh_claims.exp; match refresh_claims.token { Some(TokenWrapper::Refresh(refresh_token)) => { + let client = Client::cached().await?; + let mut is_admin = false; + // Use new refresh_token if returned let (new_refresh_token, access_token, expires_in) = - Client::exchange_refresh_token(refresh_token.clone()).await?; + client.exchange_refresh_token(refresh_token.clone()).await?; + + if CONFIG.sso_roles_enabled() { + let user_info = client.user_info(access_token.clone()).await?; + let ac = additional_claims(&user.email, vec![(user_info.additional_claims(), "user_info")])?; + is_admin = ac.is_admin(); + } create_auth_tokens( device, user, client_id, new_refresh_token.or(Some(refresh_token)), - access_token, + access_token.into_secret(), expires_in, + is_admin, ) } Some(TokenWrapper::Access(access_token)) => { @@ -455,7 +582,7 @@ pub async fn exchange_refresh_token( now, ); - _create_auth_tokens(device, None, access_claims, access_token) + _create_auth_tokens(device, None, access_claims, access_token, false) } None => err!("No token present while in SSO"), } diff --git a/src/sso_client.rs b/src/sso_client.rs index 3d2a3c48..f5a33d23 100644 --- a/src/sso_client.rs +++ b/src/sso_client.rs @@ -8,6 +8,7 @@ use once_cell::sync::Lazy; use openidconnect::core::*; use openidconnect::reqwest; use openidconnect::*; +use serde_json::Value; use crate::{ api::{ApiResult, EmptyResult}, @@ -21,16 +22,61 @@ static CLIENT_CACHE: Lazy> = Lazy::new(|| { Cache::builder().max_capacity(1).time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())).build() }); -/// OpenID Connect Core client. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +pub struct AllAdditionalClaims { + #[serde(flatten)] + pub claims: Value, +} + +impl AdditionalClaims for AllAdditionalClaims {} + +pub type MetadataClient = openidconnect::Client< + AllAdditionalClaims, + CoreAuthDisplay, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJsonWebKey, + CoreAuthPrompt, + StandardErrorResponse, + StandardTokenResponse< + IdTokenFields< + AllAdditionalClaims, + EmptyExtraTokenFields, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, + >, + CoreTokenType, + >, + CoreTokenIntrospectionResponse, + CoreRevocableToken, + CoreRevocationErrorResponse, + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointMaybeSet, + EndpointMaybeSet, +>; + pub type CustomClient = openidconnect::Client< - EmptyAdditionalClaims, + AllAdditionalClaims, CoreAuthDisplay, CoreGenderClaim, CoreJweContentEncryptionAlgorithm, CoreJsonWebKey, CoreAuthPrompt, StandardErrorResponse, - CoreTokenResponse, + StandardTokenResponse< + IdTokenFields< + AllAdditionalClaims, + EmptyExtraTokenFields, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, + >, + CoreTokenType, + >, CoreTokenIntrospectionResponse, CoreRevocableToken, CoreRevocationErrorResponse, @@ -66,7 +112,7 @@ impl Client { Ok(metadata) => metadata, }; - let base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); + let base_client = MetadataClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); let token_uri = match base_client.token_uri() { Some(uri) => uri.clone(), @@ -145,7 +191,7 @@ impl Client { ) -> ApiResult<( StandardTokenResponse< IdTokenFields< - EmptyAdditionalClaims, + AllAdditionalClaims, EmptyExtraTokenFields, CoreGenderClaim, CoreJweContentEncryptionAlgorithm, @@ -153,7 +199,7 @@ impl Client { >, CoreTokenType, >, - IdTokenClaims, + IdTokenClaims, )> { let oidc_code = AuthorizationCode::new(code.to_string()); @@ -196,7 +242,10 @@ impl Client { } } - pub async fn user_info(&self, access_token: AccessToken) -> ApiResult { + pub async fn user_info( + &self, + access_token: AccessToken, + ) -> ApiResult> { 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), @@ -229,20 +278,19 @@ impl Client { } pub async fn exchange_refresh_token( + &self, refresh_token: String, - ) -> ApiResult<(Option, String, Option)> { + ) -> ApiResult<(Option, AccessToken, Option)> { let rt = RefreshToken::new(refresh_token); - 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, - }; + let token_response = match self.core_client.exchange_refresh_token(&rt).request_async(&self.http_client).await { + Err(err) => err!(format!("Request to exchange_refresh_token endpoint failed: {:?}", err)), + Ok(token_response) => token_response, + }; Ok(( token_response.refresh_token().map(|token| token.secret().clone()), - token_response.access_token().secret().clone(), + token_response.access_token().clone(), token_response.expires_in(), )) } diff --git a/src/static/templates/admin/login.hbs b/src/static/templates/admin/login.hbs index 3ea94aec..d04f4469 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 fb066cb4..9d02323b 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