mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2025-12-16 04:09:03 +00:00
Allow SSO role mapping to add admin cookie
Co-authored-by: Fabian Fischer <nodomain@users.noreply.github.com>
This commit is contained in:
parent
319d982113
commit
ea059e36a9
@ -521,6 +521,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 ###
|
||||
########################
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -5742,6 +5742,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"subtle",
|
||||
"svg-hush",
|
||||
"syslog",
|
||||
|
||||
@ -85,6 +85,7 @@ tokio-util = { version = "0.7.17", features = ["compat"]}
|
||||
# A generic serialization/deserialization framework
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
serde_with = "3.15.0"
|
||||
|
||||
# A safe, extensible ORM and Query builder
|
||||
diesel = { version = "2.3.3", features = ["chrono", "r2d2", "numeric"] }
|
||||
|
||||
@ -20,13 +20,45 @@ 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
|
||||
|
||||
@ -34,6 +34,8 @@ services:
|
||||
- SSO_ENABLED
|
||||
- SSO_FRONTEND
|
||||
- SSO_ONLY
|
||||
- SSO_ROLES_DEFAULT_TO_USER
|
||||
- SSO_ROLES_ENABLED
|
||||
- SSO_SCOPES
|
||||
restart: "no"
|
||||
depends_on:
|
||||
|
||||
56
playwright/tests/sso_roles.spec.ts
Normal file
56
playwright/tests/sso_roles.spec.ts
Normal file
@ -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.locator("input[type=email].vw-email-sso").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');
|
||||
});
|
||||
});
|
||||
@ -38,7 +38,10 @@ use crate::{
|
||||
};
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
@ -166,6 +169,7 @@ fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult<Ht
|
||||
let json = json!({
|
||||
"page_content": "admin/login",
|
||||
"error": msg,
|
||||
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_roles_enabled(),
|
||||
"redirect": redirect,
|
||||
"urlpath": CONFIG.domain_path()
|
||||
});
|
||||
@ -181,6 +185,24 @@ struct LoginForm {
|
||||
redirect: Option<String>,
|
||||
}
|
||||
|
||||
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 = "<data>")]
|
||||
fn post_admin_login(
|
||||
data: Form<LoginForm>,
|
||||
@ -207,17 +229,7 @@ fn post_admin_login(
|
||||
)))
|
||||
} 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 {
|
||||
@ -275,6 +287,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))
|
||||
@ -343,7 +356,7 @@ async fn test_smtp(data: Json<InviteData>, _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())
|
||||
}
|
||||
|
||||
@ -847,8 +860,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"));
|
||||
}
|
||||
|
||||
@ -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,
|
||||
@ -21,7 +22,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::{
|
||||
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OrganizationApiKey, OrganizationId,
|
||||
@ -54,6 +55,8 @@ async fn login(
|
||||
data: Form<ConnectData>,
|
||||
client_header: ClientHeaders,
|
||||
client_version: Option<ClientVersion>,
|
||||
cookies: &CookieJar<'_>,
|
||||
secure: Secure,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
let data: ConnectData = data.into_inner();
|
||||
@ -63,7 +66,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, &conn, &client_header.ip).await
|
||||
_refresh_login(data, &conn, cookies, &client_header.ip, secure).await
|
||||
}
|
||||
"password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
|
||||
"password" => {
|
||||
@ -97,7 +100,7 @@ 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, &conn, &client_header.ip, &client_version).await
|
||||
_sso_login(data, &mut user_id, &conn, cookies, &client_header.ip, secure, &client_version).await
|
||||
}
|
||||
"authorization_code" => err!("SSO sign-in is not available"),
|
||||
t => err!("Invalid type", t),
|
||||
@ -128,7 +131,13 @@ async fn login(
|
||||
}
|
||||
|
||||
// Return Status::Unauthorized to trigger logout
|
||||
async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||
async fn _refresh_login(
|
||||
data: ConnectData,
|
||||
conn: &DbConn,
|
||||
cookies: &CookieJar<'_>,
|
||||
ip: &ClientIp,
|
||||
secure: Secure,
|
||||
) -> JsonResult {
|
||||
// Extract token
|
||||
let refresh_token = match data.refresh_token {
|
||||
Some(token) => token,
|
||||
@ -145,10 +154,17 @@ async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> Json
|
||||
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(),
|
||||
@ -163,11 +179,14 @@ async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> Json
|
||||
}
|
||||
|
||||
// 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<UserId>,
|
||||
conn: &DbConn,
|
||||
cookies: &CookieJar<'_>,
|
||||
ip: &ClientIp,
|
||||
secure: Secure,
|
||||
client_version: &Option<ClientVersion>,
|
||||
) -> JsonResult {
|
||||
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
|
||||
@ -290,28 +309,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
|
||||
}
|
||||
|
||||
@ -1155,6 +1155,7 @@ pub struct RefreshJwtClaims {
|
||||
pub struct AuthTokens {
|
||||
pub refresh_claims: RefreshJwtClaims,
|
||||
pub access_claims: LoginJwtClaims,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl AuthTokens {
|
||||
@ -1198,6 +1199,7 @@ impl AuthTokens {
|
||||
Self {
|
||||
refresh_claims,
|
||||
access_claims,
|
||||
is_admin: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1207,7 +1209,7 @@ pub async fn refresh_tokens(
|
||||
refresh_token: &str,
|
||||
client_id: Option<String>,
|
||||
conn: &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);
|
||||
@ -1235,7 +1237,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"),
|
||||
@ -1243,5 +1245,5 @@ pub async fn refresh_tokens(
|
||||
_ => err!("Invalid auth method, cannot refresh token"),
|
||||
};
|
||||
|
||||
Ok((device, auth_tokens))
|
||||
Ok((user, device, auth_tokens))
|
||||
}
|
||||
|
||||
@ -825,6 +825,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
|
||||
|
||||
157
src/sso.rs
157
src/sso.rs
@ -4,6 +4,8 @@ use chrono::Utc;
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use mini_moka::sync::Cache;
|
||||
use regex::Regex;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_with::{serde_as, DefaultOnError};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
@ -11,10 +13,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,
|
||||
};
|
||||
|
||||
@ -138,15 +140,18 @@ impl BasicTokenClaims {
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_token_claims(token_name: &str, token: &str) -> ApiResult<BasicTokenClaims> {
|
||||
// IdToken validation is handled by IdToken.claims
|
||||
// This is only used to retrieve additional claims which are configurable
|
||||
// Or to try to parse access_token and refresh_tken as JWT to find exp
|
||||
fn insecure_decode<T: DeserializeOwned>(token_name: &str, token: &str) -> ApiResult<T> {
|
||||
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::<T>(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}")),
|
||||
}
|
||||
}
|
||||
|
||||
@ -209,6 +214,28 @@ impl OIDCIdentifier {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AdditionalClaims {
|
||||
role: Option<UserRole>,
|
||||
}
|
||||
|
||||
impl AdditionalClaims {
|
||||
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<T: DeserializeOwned>(#[serde_as(as = "Vec<DefaultOnError>")] Vec<Option<T>>);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticatedUser {
|
||||
pub refresh_token: Option<String>,
|
||||
@ -218,6 +245,13 @@ pub struct AuthenticatedUser {
|
||||
pub email: String,
|
||||
pub email_verified: Option<bool>,
|
||||
pub user_name: Option<String>,
|
||||
pub role: Option<UserRole>,
|
||||
}
|
||||
|
||||
impl AuthenticatedUser {
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.role.as_ref().is_some_and(|x| x == &UserRole::Admin)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@ -229,6 +263,44 @@ pub struct UserInformation {
|
||||
pub user_name: Option<String>,
|
||||
}
|
||||
|
||||
// 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<T: DeserializeOwned + Ord>(email: &str, token: &serde_json::Value, source: &str) -> Option<T> {
|
||||
use crate::serde::Deserialize;
|
||||
if let Some(json_roles) = token.pointer(&CONFIG.sso_roles_token_path()) {
|
||||
match UserRoles::<T>::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<AdditionalClaims> {
|
||||
let mut role: Option<UserRole> = 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(AdditionalClaims {
|
||||
role,
|
||||
})
|
||||
}
|
||||
|
||||
async fn decode_code_claims(code: &str, conn: &DbConn) -> ApiResult<(OIDCCode, OIDCState)> {
|
||||
match auth::decode_jwt::<OIDCCodeClaims>(code, SSO_JWT_ISSUER.to_string()) {
|
||||
Ok(code_claims) => match code_claims.code {
|
||||
@ -293,6 +365,21 @@ pub async fn exchange_code(wrapped_code: &str, conn: &DbConn) -> ApiResult<UserI
|
||||
|
||||
let user_name = id_claims.preferred_username().map(|un| un.to_string());
|
||||
|
||||
let additional_claims = additional_claims(
|
||||
&email,
|
||||
vec![(id_claims.additional_claims(), "id_token"), (user_info.additional_claims(), "user_info")],
|
||||
)?;
|
||||
|
||||
if CONFIG.sso_roles_enabled() && !CONFIG.sso_roles_default_to_user() && additional_claims.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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let refresh_token = token_response.refresh_token().map(|t| t.secret());
|
||||
if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) {
|
||||
error!("Scope offline_access is present but response contain no refresh_token");
|
||||
@ -308,6 +395,7 @@ pub async fn exchange_code(wrapped_code: &str, conn: &DbConn) -> ApiResult<UserI
|
||||
email: email.clone(),
|
||||
email_verified,
|
||||
user_name: user_name.clone(),
|
||||
role: additional_claims.role,
|
||||
};
|
||||
|
||||
debug!("Authenticated user {authenticated_user:?}");
|
||||
@ -324,17 +412,43 @@ pub async fn exchange_code(wrapped_code: &str, conn: &DbConn) -> ApiResult<UserI
|
||||
}
|
||||
|
||||
// User has passed 2FA flow we can delete `nonce` and clear the cache.
|
||||
pub async fn redeem(state: &OIDCState, conn: &DbConn) -> ApiResult<AuthenticatedUser> {
|
||||
pub async fn redeem(
|
||||
user: &User,
|
||||
device: &Device,
|
||||
client_id: Option<String>,
|
||||
sso_user: Option<SsoUser>,
|
||||
state: &OIDCState,
|
||||
conn: &DbConn,
|
||||
) -> ApiResult<AuthTokens> {
|
||||
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).
|
||||
@ -346,11 +460,12 @@ pub fn create_auth_tokens(
|
||||
refresh_token: Option<String>,
|
||||
access_token: String,
|
||||
expires_in: Option<Duration>,
|
||||
is_admin: bool,
|
||||
) -> ApiResult<AuthTokens> {
|
||||
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::<BasicTokenClaims>("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"),
|
||||
@ -359,7 +474,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))
|
||||
}
|
||||
@ -370,9 +485,10 @@ fn _create_auth_tokens(
|
||||
refresh_token: Option<String>,
|
||||
access_claims: auth::LoginJwtClaims,
|
||||
access_token: String,
|
||||
is_admin: bool,
|
||||
) -> ApiResult<AuthTokens> {
|
||||
let (nbf, exp, token) = if let Some(rt) = refresh_token {
|
||||
match decode_token_claims("refresh_token", &rt) {
|
||||
match insecure_decode::<BasicTokenClaims>("refresh_token", &rt) {
|
||||
Err(_) => {
|
||||
let time_now = Utc::now();
|
||||
let exp = (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp();
|
||||
@ -401,6 +517,7 @@ fn _create_auth_tokens(
|
||||
Ok(AuthTokens {
|
||||
refresh_claims,
|
||||
access_claims,
|
||||
is_admin,
|
||||
})
|
||||
}
|
||||
|
||||
@ -408,25 +525,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<String>,
|
||||
refresh_claims: auth::RefreshJwtClaims,
|
||||
) -> ApiResult<AuthTokens> {
|
||||
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)) => {
|
||||
@ -449,7 +576,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"),
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ use std::{borrow::Cow, sync::LazyLock, time::Duration};
|
||||
use mini_moka::sync::Cache;
|
||||
use openidconnect::{core::*, reqwest, *};
|
||||
use regex::Regex;
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
@ -17,16 +18,61 @@ static CLIENT_CACHE: LazyLock<Cache<String, Client>> = LazyLock::new(|| {
|
||||
Cache::builder().max_capacity(1).time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())).build()
|
||||
});
|
||||
|
||||
/// OpenID Connect Core client.
|
||||
pub type CustomClient = openidconnect::Client<
|
||||
EmptyAdditionalClaims,
|
||||
#[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<CoreErrorResponseType>,
|
||||
CoreTokenResponse,
|
||||
StandardTokenResponse<
|
||||
IdTokenFields<
|
||||
AllAdditionalClaims,
|
||||
EmptyExtraTokenFields,
|
||||
CoreGenderClaim,
|
||||
CoreJweContentEncryptionAlgorithm,
|
||||
CoreJwsSigningAlgorithm,
|
||||
>,
|
||||
CoreTokenType,
|
||||
>,
|
||||
CoreTokenIntrospectionResponse,
|
||||
CoreRevocableToken,
|
||||
CoreRevocationErrorResponse,
|
||||
EndpointSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointMaybeSet,
|
||||
EndpointMaybeSet,
|
||||
>;
|
||||
|
||||
pub type CustomClient = openidconnect::Client<
|
||||
AllAdditionalClaims,
|
||||
CoreAuthDisplay,
|
||||
CoreGenderClaim,
|
||||
CoreJweContentEncryptionAlgorithm,
|
||||
CoreJsonWebKey,
|
||||
CoreAuthPrompt,
|
||||
StandardErrorResponse<CoreErrorResponseType>,
|
||||
StandardTokenResponse<
|
||||
IdTokenFields<
|
||||
AllAdditionalClaims,
|
||||
EmptyExtraTokenFields,
|
||||
CoreGenderClaim,
|
||||
CoreJweContentEncryptionAlgorithm,
|
||||
CoreJwsSigningAlgorithm,
|
||||
>,
|
||||
CoreTokenType,
|
||||
>,
|
||||
CoreTokenIntrospectionResponse,
|
||||
CoreRevocableToken,
|
||||
CoreRevocationErrorResponse,
|
||||
@ -62,7 +108,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(),
|
||||
@ -141,7 +187,7 @@ impl Client {
|
||||
) -> ApiResult<(
|
||||
StandardTokenResponse<
|
||||
IdTokenFields<
|
||||
EmptyAdditionalClaims,
|
||||
AllAdditionalClaims,
|
||||
EmptyExtraTokenFields,
|
||||
CoreGenderClaim,
|
||||
CoreJweContentEncryptionAlgorithm,
|
||||
@ -149,7 +195,7 @@ impl Client {
|
||||
>,
|
||||
CoreTokenType,
|
||||
>,
|
||||
IdTokenClaims<EmptyAdditionalClaims, CoreGenderClaim>,
|
||||
IdTokenClaims<AllAdditionalClaims, CoreGenderClaim>,
|
||||
)> {
|
||||
let oidc_code = AuthorizationCode::new(code.to_string());
|
||||
|
||||
@ -192,7 +238,10 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn user_info(&self, access_token: AccessToken) -> ApiResult<CoreUserInfoClaims> {
|
||||
pub async fn user_info(
|
||||
&self,
|
||||
access_token: AccessToken,
|
||||
) -> ApiResult<UserInfoClaims<AllAdditionalClaims, CoreGenderClaim>> {
|
||||
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),
|
||||
@ -225,20 +274,19 @@ impl Client {
|
||||
}
|
||||
|
||||
pub async fn exchange_refresh_token(
|
||||
&self,
|
||||
refresh_token: String,
|
||||
) -> ApiResult<(Option<String>, String, Option<Duration>)> {
|
||||
) -> ApiResult<(Option<String>, AccessToken, Option<Duration>)> {
|
||||
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(),
|
||||
))
|
||||
}
|
||||
|
||||
@ -8,17 +8,23 @@
|
||||
{{/if}}
|
||||
|
||||
<div class="align-items-center p-3 mb-3 text-opacity-75 text-light bg-danger rounded shadow">
|
||||
<div>
|
||||
<h6 class="mb-0 text-light">Authentication key needed to continue</h6>
|
||||
<small>Please provide it below:</small>
|
||||
{{#if sso_only}}
|
||||
<div>
|
||||
<h6 class="mb-0 text-light">You do not have access to the admin panel (or the admin session expired and you need to log again)</h6>
|
||||
</div>
|
||||
{{else}}
|
||||
<div>
|
||||
<h6 class="mb-0 text-light">Authentication key needed to continue</h6>
|
||||
<small>Please provide it below:</small>
|
||||
|
||||
<form class="form-inline" method="post" action="{{urlpath}}/admin">
|
||||
<input type="password" autocomplete="password" class="form-control w-50 mr-2" name="token" placeholder="Enter admin token" autofocus="autofocus">
|
||||
{{#if redirect}}
|
||||
<input type="hidden" id="redirect" name="redirect" value="/{{redirect}}">
|
||||
{{/if}}
|
||||
<button type="submit" class="btn btn-primary mt-2">Enter</button>
|
||||
</form>
|
||||
</div>
|
||||
<form class="form-inline" method="post" action="{{urlpath}}/admin">
|
||||
<input type="password" autocomplete="password" class="form-control w-50 mr-2" name="token" placeholder="Enter admin token" autofocus="autofocus">
|
||||
{{#if redirect}}
|
||||
<input type="hidden" id="redirect" name="redirect" value="/{{redirect}}">
|
||||
{{/if}}
|
||||
<button type="submit" class="btn btn-primary mt-2">Enter</button>
|
||||
</form>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
<main class="container-xl">
|
||||
<div id="admin_token_warning" class="alert alert-warning alert-dismissible fade show d-none">
|
||||
<button type="button" class="btn-close" data-bs-target="admin_token_warning" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
You are using a plain text `ADMIN_TOKEN` which is insecure.<br>
|
||||
Please generate a secure Argon2 PHC string by using `vaultwarden hash` or `argon2`.<br>
|
||||
See: <a href="https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page#secure-the-admin_token" target="_blank" rel="noopener noreferrer">Enabling admin page - Secure the `ADMIN_TOKEN`</a>
|
||||
</div>
|
||||
{{#unless page_data.sso_only}}
|
||||
<div id="admin_token_warning" class="alert alert-warning alert-dismissible fade show d-none">
|
||||
<button type="button" class="btn-close" data-bs-target="admin_token_warning" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
You are using a plain text `ADMIN_TOKEN` which is insecure.<br>
|
||||
Please generate a secure Argon2 PHC string by using `vaultwarden hash` or `argon2`.<br>
|
||||
See: <a href="https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page#secure-the-admin_token" target="_blank" rel="noopener noreferrer">Enabling admin page - Secure the `ADMIN_TOKEN`</a>
|
||||
</div>
|
||||
{{/unless}}
|
||||
<div id="config-block" class="align-items-center p-3 mb-3 bg-secondary rounded shadow">
|
||||
<div>
|
||||
<h6 class="text-white mb-3">Configuration</h6>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user