use std::fmt::Write as _;
use std::sync::Arc;
use crate::config::SaTokenConfig;
pub const LOGIN_TYPE_DEFAULT: &str = "default";
pub const LOGIN_TYPE_LOGIN: &str = "login";
pub const LOGIN_TYPE_SSO: &str = "sso";
pub const LOGIN_TYPE_SSO_CLIENT: &str = "sso_client";
const COLON_ESCAPE: &str = "%3A";
const MAX_LOGIN_ID_LEN: usize = 512;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyError {
EmptyLoginId,
LoginIdTooLong {
actual: usize,
max: usize,
},
NamespacedIdUnsupportedByLayout {
api: &'static str,
},
}
impl std::fmt::Display for KeyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyLoginId => write!(f, "login_id must not be empty"),
Self::LoginIdTooLong { actual, max } => write!(
f,
"login_id is too long: {actual} bytes exceeds the maximum of {max} bytes"
),
Self::NamespacedIdUnsupportedByLayout { api } => write!(
f,
"{api} requires SaKeyLayout::ThreeSegment; use the (login_type, login_id) variant instead"
),
}
}
}
impl std::error::Error for KeyError {}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(transparent)]
pub struct LoginId(String);
impl LoginId {
#[inline]
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn try_new(id: impl Into<String>) -> Result<Self, KeyError> {
let id = id.into();
SaKeys::validate_login_id(&id)?;
Ok(Self(id))
}
#[inline]
pub fn as_str(&self) -> &str {
&self.0
}
#[inline]
pub fn into_inner(self) -> String {
self.0
}
}
impl std::fmt::Display for LoginId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for LoginId {
#[inline]
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<&str> for LoginId {
#[inline]
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
impl From<String> for LoginId {
#[inline]
fn from(value: String) -> Self {
Self(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(transparent)]
pub struct AccountNs(String);
impl AccountNs {
#[inline]
pub fn from_trusted(ns: impl Into<String>) -> Self {
Self(ns.into())
}
#[inline]
pub fn as_str(&self) -> &str {
&self.0
}
#[inline]
pub fn into_inner(self) -> String {
self.0
}
}
impl std::fmt::Display for AccountNs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for AccountNs {
#[inline]
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum SaKeyLayout {
#[default]
ThreeSegment,
JavaFourSegment,
}
#[derive(Debug, Clone)]
pub struct SaKeys {
root: Arc<str>,
layout: SaKeyLayout,
token_colon: Arc<str>,
}
impl SaKeys {
pub fn new(prefix: impl AsRef<str>) -> Self {
let root: Arc<str> = Arc::from(prefix.as_ref());
let token_colon = Arc::from(format!("{root}token:"));
Self {
root,
layout: SaKeyLayout::ThreeSegment,
token_colon,
}
}
pub fn with_layout(root: impl AsRef<str>, layout: SaKeyLayout) -> Self {
let root: Arc<str> = match layout {
SaKeyLayout::ThreeSegment => Arc::from(root.as_ref()),
SaKeyLayout::JavaFourSegment => Arc::from(root.as_ref().trim_end_matches(':')),
};
let token_colon = Arc::from(format!("{root}token:"));
Self {
root,
layout,
token_colon,
}
}
pub fn from_config(config: &SaTokenConfig) -> Self {
match config.key_layout {
SaKeyLayout::ThreeSegment => {
Self::with_layout(&config.storage_key_prefix, SaKeyLayout::ThreeSegment)
}
SaKeyLayout::JavaFourSegment => {
Self::with_layout(&config.token_name, SaKeyLayout::JavaFourSegment)
}
}
}
#[inline]
pub fn prefix(&self) -> &str {
&self.root
}
#[inline]
pub fn layout(&self) -> SaKeyLayout {
self.layout
}
#[inline]
fn is_java(&self) -> bool {
matches!(self.layout, SaKeyLayout::JavaFourSegment)
}
pub fn account_ns(login_type: &str, login_id: &LoginId) -> AccountNs {
let id = login_id.as_str();
if Self::is_default_login_type(login_type) {
return AccountNs(id.to_string());
}
let needs_escape = id.contains(':');
let escaped_extra = if needs_escape {
id.matches(':').count() * (COLON_ESCAPE.len() - 1)
} else {
0
};
let mut out = String::with_capacity(login_type.len() + 1 + id.len() + escaped_extra);
out.push_str(login_type);
out.push(':');
if needs_escape {
Self::push_escaped(&mut out, id);
} else {
out.push_str(id);
}
AccountNs(out)
}
#[inline]
pub fn is_default_login_type(login_type: &str) -> bool {
login_type.is_empty() || login_type == LOGIN_TYPE_DEFAULT || login_type == LOGIN_TYPE_LOGIN
}
#[inline]
fn push_escaped(out: &mut String, src: &str) {
for ch in src.chars() {
if ch == ':' {
out.push_str(COLON_ESCAPE);
} else {
out.push(ch);
}
}
}
pub fn validate_login_id(login_id: &str) -> Result<(), KeyError> {
if login_id.is_empty() {
return Err(KeyError::EmptyLoginId);
}
if login_id.len() > MAX_LOGIN_ID_LEN {
return Err(KeyError::LoginIdTooLong {
actual: login_id.len(),
max: MAX_LOGIN_ID_LEN,
});
}
Ok(())
}
fn build_global(&self, category: &str, id: &str, login_type: Option<&str>) -> String {
match self.layout {
SaKeyLayout::ThreeSegment => {
let mut out =
String::with_capacity(self.root.len() + category.len() + 1 + id.len());
out.push_str(&self.root);
out.push_str(category);
out.push(':');
out.push_str(id);
out
}
SaKeyLayout::JavaFourSegment => {
let lt = login_type.unwrap_or(LOGIN_TYPE_LOGIN);
let mut out = String::with_capacity(
self.root.len() + 1 + lt.len() + 1 + category.len() + 1 + id.len(),
);
let _ = write!(out, "{}:{}:{}:{}", self.root, lt, category, id);
out
}
}
}
fn build_account(&self, category: &str, login_type: &str, login_id: &str) -> String {
match self.layout {
SaKeyLayout::ThreeSegment => {
if Self::is_default_login_type(login_type) {
let mut out = String::with_capacity(
self.root.len() + category.len() + 1 + login_id.len(),
);
out.push_str(&self.root);
out.push_str(category);
out.push(':');
out.push_str(login_id);
return out;
}
let escaped_extra = login_id.matches(':').count() * (COLON_ESCAPE.len() - 1);
let mut out = String::with_capacity(
self.root.len()
+ category.len()
+ 1
+ login_type.len()
+ 1
+ login_id.len()
+ escaped_extra,
);
out.push_str(&self.root);
out.push_str(category);
out.push(':');
out.push_str(login_type);
out.push(':');
Self::push_escaped(&mut out, login_id);
out
}
SaKeyLayout::JavaFourSegment => {
let lt = if login_type.is_empty() {
LOGIN_TYPE_LOGIN
} else {
login_type
};
let mut out = String::with_capacity(
self.root.len() + 1 + lt.len() + 1 + category.len() + 1 + login_id.len(),
);
let _ = write!(out, "{}:{}:{}:{}", self.root, lt, category, login_id);
out
}
}
}
fn build_from_ns(
&self,
category: &str,
ns: &AccountNs,
api: &'static str,
) -> Result<String, KeyError> {
if self.is_java() {
return Err(KeyError::NamespacedIdUnsupportedByLayout { api });
}
Ok(self.build_global(category, ns.as_str(), None))
}
#[deprecated(
since = "0.1.19",
note = "Use a named key method (token_info / login_token / ...) so the key layout is respected"
)]
pub fn make_key(&self, suffix: &str, id: &str) -> String {
let mut out = String::with_capacity(self.root.len() + suffix.len() + id.len());
out.push_str(&self.root);
out.push_str(suffix);
out.push_str(id);
out
}
#[inline]
pub fn token_info(&self, token: &str) -> String {
self.build_global("token", token, None)
}
#[inline]
pub fn token_info_with_type(&self, login_type: &str, token: &str) -> String {
self.build_global("token", token, Some(login_type))
}
#[inline]
pub fn token_id_mapping(&self, token: &str) -> String {
self.build_global("token-id", token, None)
}
#[inline]
pub fn token_session(&self, token: &str) -> String {
self.build_global("token-session", token, None)
}
#[inline]
pub fn token_session_with_type(&self, login_type: &str, token: &str) -> String {
self.build_global("token-session", token, Some(login_type))
}
#[inline]
pub fn last_active(&self, token: &str) -> String {
self.build_global("last-active", token, None)
}
#[inline]
pub fn last_active_with_type(&self, login_type: &str, token: &str) -> String {
self.build_global("last-active", token, Some(login_type))
}
#[inline]
pub fn login_token(&self, login_type: &str, login_id: &str) -> String {
self.build_account("login:token", login_type, login_id)
}
#[inline]
pub fn login_token_index(&self, login_type: &str, login_id: &str) -> String {
self.build_account("login:tokens", login_type, login_id)
}
#[inline]
pub fn account_session(&self, login_type: &str, login_id: &str) -> String {
self.build_account("session", login_type, login_id)
}
#[inline]
pub fn session_by_ns(&self, ns: &AccountNs) -> Result<String, KeyError> {
self.build_from_ns("session", ns, "SaKeys::session_by_ns")
}
#[inline]
pub fn permission(&self, login_type: &str, login_id: &str) -> String {
self.build_account("permission", login_type, login_id)
}
#[inline]
pub fn role(&self, login_type: &str, login_id: &str) -> String {
self.build_account("role", login_type, login_id)
}
pub fn disable(&self, login_type: &str, login_id: &str, service: &str) -> String {
match self.layout {
SaKeyLayout::ThreeSegment => {
let mut out = self.build_account("disable", login_type, login_id);
out.push(':');
out.push_str(service);
out
}
SaKeyLayout::JavaFourSegment => {
let lt = if login_type.is_empty() {
LOGIN_TYPE_LOGIN
} else {
login_type
};
let mut out = String::with_capacity(
self.root.len() + 1 + lt.len() + 9 + service.len() + 1 + login_id.len(),
);
let _ = write!(out, "{}:{}:disable:{}:{}", self.root, lt, service, login_id);
out
}
}
}
pub fn disable_by_ns(&self, ns: &AccountNs, service: &str) -> Result<String, KeyError> {
if self.is_java() {
return Err(KeyError::NamespacedIdUnsupportedByLayout {
api: "SaKeys::disable_by_ns",
});
}
let mut out = self.build_global("disable", ns.as_str(), None);
out.push(':');
out.push_str(service);
Ok(out)
}
pub fn safe(&self, token: &str, service: &str) -> String {
self.safe_with_type(LOGIN_TYPE_LOGIN, token, service)
}
pub fn safe_with_type(&self, login_type: &str, token: &str, service: &str) -> String {
match self.layout {
SaKeyLayout::ThreeSegment => {
let mut out = self.build_global("safe", token, None);
out.push(':');
out.push_str(service);
out
}
SaKeyLayout::JavaFourSegment => {
let lt = if login_type.is_empty() {
LOGIN_TYPE_LOGIN
} else {
login_type
};
let mut out = String::with_capacity(
self.root.len() + 1 + lt.len() + 6 + service.len() + 1 + token.len(),
);
let _ = write!(out, "{}:{}:safe:{}:{}", self.root, lt, service, token);
out
}
}
}
#[inline]
pub fn nonce(&self, nonce_value: &str) -> String {
self.build_global("nonce", nonce_value, None)
}
#[inline]
pub fn refresh(&self, refresh_token: &str) -> String {
self.build_global("refresh", refresh_token, None)
}
#[inline]
pub fn refresh_user_index(&self, login_type: &str, login_id: &str) -> String {
self.build_account("refresh:user", login_type, login_id)
}
#[inline]
pub fn refresh_user_index_by_ns(&self, ns: &AccountNs) -> Result<String, KeyError> {
self.build_from_ns("refresh:user", ns, "SaKeys::refresh_user_index_by_ns")
}
#[inline]
pub fn oauth2_client(&self, client_id: &str) -> String {
self.build_global("oauth2:client", client_id, None)
}
#[inline]
pub fn oauth2_code(&self, code: &str) -> String {
self.build_global("oauth2:code", code, None)
}
#[inline]
pub fn oauth2_token(&self, access_token: &str) -> String {
self.build_global("oauth2:token", access_token, None)
}
#[inline]
pub fn oauth2_refresh(&self, refresh_token: &str) -> String {
self.build_global("oauth2:refresh", refresh_token, None)
}
#[inline]
pub fn sso_ticket(&self, ticket_id: &str) -> String {
self.build_global("sso:ticket", ticket_id, None)
}
#[inline]
pub fn sso_session(&self, login_id: &str) -> String {
self.build_global("sso:session", login_id, None)
}
#[inline]
pub fn sso_login_token(&self, login_type: &str, login_id: &str) -> String {
self.login_token(login_type, login_id)
}
#[inline]
pub fn online(&self, login_id: &str, token: &str) -> String {
let mut out = self.build_global("online", login_id, None);
out.push(':');
out.push_str(token);
out
}
#[inline]
pub fn online_with_type(&self, login_type: &str, login_id: &str, token: &str) -> String {
let mut out = self.build_account("online", login_type, login_id);
out.push(':');
out.push_str(token);
out
}
#[inline]
pub fn online_index(&self, login_id: &str) -> String {
self.build_global("online:index", login_id, None)
}
#[inline]
pub fn online_index_with_type(&self, login_type: &str, login_id: &str) -> String {
self.build_account("online:index", login_type, login_id)
}
#[inline]
pub fn online_users_set(&self) -> String {
self.build_global("online", "users", None)
}
#[inline]
pub fn distributed_session(&self, session_id: &str) -> String {
self.build_global("dsession", session_id, None)
}
#[inline]
pub fn distributed_session_index(&self, login_id: &str) -> String {
self.build_global("dsession:index", login_id, None)
}
#[inline]
pub fn distributed_service(&self, service_id: &str) -> String {
self.build_global("dservice", service_id, None)
}
#[inline]
pub fn same_token(&self) -> String {
self.build_global("var", "same-token", None)
}
#[inline]
pub fn same_token_past(&self) -> String {
self.build_global("var", "same-token-past", None)
}
#[inline]
pub fn sign_nonce(&self, nonce: &str) -> String {
self.build_global("sign-nonce", nonce, None)
}
#[inline]
pub fn temp_token(&self, namespace: &str, token: &str) -> String {
let mut cat = String::from("temp-token:");
cat.push_str(namespace);
self.build_global(&cat, token, None)
}
#[inline]
pub fn temp_index(&self, namespace: &str, value_digest: &str) -> String {
let mut cat = String::from("temp-index:");
cat.push_str(namespace);
self.build_global(&cat, value_digest, None)
}
pub fn category_prefix(&self, category: &str, login_type: Option<&str>) -> String {
match self.layout {
SaKeyLayout::ThreeSegment => {
let mut out = String::with_capacity(self.root.len() + category.len() + 1);
out.push_str(&self.root);
out.push_str(category);
out.push(':');
out
}
SaKeyLayout::JavaFourSegment => {
let lt = login_type.unwrap_or(LOGIN_TYPE_LOGIN);
let mut out =
String::with_capacity(self.root.len() + 1 + lt.len() + 1 + category.len() + 1);
let _ = write!(out, "{}:{}:{}:", self.root, lt, category);
out
}
}
}
#[inline]
pub fn token_key_prefix(&self, login_type: Option<&str>) -> String {
self.category_prefix("token", login_type)
}
pub fn token_scan_pattern(&self, login_type: Option<&str>) -> String {
let mut out = self.token_key_prefix(login_type);
out.push('*');
out
}
pub fn scan_pattern(&self, category: &str, login_type: Option<&str>) -> String {
let mut out = self.category_prefix(category, login_type);
out.push('*');
out
}
pub fn parse_token_from_key<'k>(
&self,
key: &'k str,
login_type: Option<&str>,
) -> Option<&'k str> {
if matches!(self.layout, SaKeyLayout::ThreeSegment)
&& login_type.map(Self::is_default_login_type).unwrap_or(true)
{
return key.strip_prefix(self.token_colon.as_ref());
}
let prefix = self.token_key_prefix(login_type);
key.strip_prefix(prefix.as_str())
}
pub fn parse_id_from_key<'k>(
&self,
key: &'k str,
category: &str,
login_type: Option<&str>,
) -> Option<&'k str> {
let prefix = self.category_prefix(category, login_type);
key.strip_prefix(prefix.as_str())
}
}
impl Default for SaKeys {
fn default() -> Self {
Self::new("sa:")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn legacy_make_key(prefix: &str, suffix: &str, id: &str) -> String {
format!("{prefix}{suffix}{id}")
}
fn id(s: &str) -> LoginId {
LoginId::new(s)
}
#[test]
fn account_ns_default_unchanged() {
assert_eq!(SaKeys::account_ns("default", &id("u1")).as_str(), "u1");
assert_eq!(SaKeys::account_ns("login", &id("u1")).as_str(), "u1");
assert_eq!(SaKeys::account_ns("", &id("u1")).as_str(), "u1");
assert_eq!(SaKeys::account_ns("admin", &id("u1")).as_str(), "admin:u1");
}
#[test]
fn account_ns_colon_escaping() {
assert_eq!(SaKeys::account_ns("default", &id("a:b")).as_str(), "a:b");
assert_eq!(
SaKeys::account_ns("admin", &id("a:b")).as_str(),
"admin:a%3Ab"
);
assert_ne!(
SaKeys::account_ns("a", &id("b:c")).as_str(),
SaKeys::account_ns("a:b", &id("c")).as_str()
);
}
#[test]
fn three_segment_matches_legacy_make_key() {
let keys = SaKeys::new("sa:");
let login_id = "user_1";
let token = "abc123";
assert_eq!(
keys.token_info(token),
legacy_make_key("sa:", "token:", token)
);
assert_eq!(
keys.login_token("default", login_id),
legacy_make_key("sa:", "login:token:", login_id)
);
assert_eq!(
keys.login_token_index("default", login_id),
legacy_make_key("sa:", "login:tokens:", login_id)
);
assert_eq!(
keys.account_session("default", login_id),
legacy_make_key("sa:", "session:", login_id)
);
assert_eq!(
keys.permission("default", login_id),
legacy_make_key("sa:", "permission:", login_id)
);
assert_eq!(
keys.role("default", login_id),
legacy_make_key("sa:", "role:", login_id)
);
assert_eq!(
keys.token_id_mapping(token),
legacy_make_key("sa:", "token-id:", token)
);
assert_eq!(
keys.token_session(token),
legacy_make_key("sa:", "token-session:", token)
);
assert_eq!(
keys.disable("default", login_id, "login"),
legacy_make_key("sa:", "disable:", &format!("{login_id}:login"))
);
assert_eq!(
keys.safe(token, "pay"),
legacy_make_key("sa:", "safe:", &format!("{token}:pay"))
);
assert_eq!(
keys.nonce("nonce_1"),
legacy_make_key("sa:", "nonce:", "nonce_1")
);
assert_eq!(
keys.refresh("rt_1"),
legacy_make_key("sa:", "refresh:", "rt_1")
);
assert_eq!(
keys.refresh_user_index("default", login_id),
legacy_make_key("sa:", "refresh:user:", login_id)
);
}
#[test]
fn three_segment_admin_account_keys() {
let keys = SaKeys::new("sa:");
assert_eq!(
keys.login_token("admin", "10001"),
"sa:login:token:admin:10001"
);
assert_eq!(
keys.login_token_index("admin", "10001"),
"sa:login:tokens:admin:10001"
);
assert_eq!(
keys.account_session("admin", "10001"),
"sa:session:admin:10001"
);
}
#[test]
fn session_by_ns_three_segment() {
let keys = SaKeys::new("sa:");
let ns = SaKeys::account_ns("admin", &id("10001"));
assert_eq!(keys.session_by_ns(&ns).unwrap(), "sa:session:admin:10001");
}
#[test]
fn custom_prefix_matches_legacy_make_key() {
let keys = SaKeys::new("myapp:");
assert_eq!(keys.token_info("t1"), "myapp:token:t1");
assert_eq!(keys.login_token("default", "u1"), "myapp:login:token:u1");
}
#[test]
fn java_four_segment_layout() {
let keys = SaKeys::with_layout("satoken", SaKeyLayout::JavaFourSegment);
assert_eq!(keys.token_info("abc"), "satoken:login:token:abc");
assert_eq!(
keys.token_info_with_type("admin", "abc"),
"satoken:admin:token:abc"
);
assert_eq!(
keys.login_token("admin", "u1"),
"satoken:admin:login:token:u1"
);
assert_eq!(
keys.account_session("admin", "u1"),
"satoken:admin:session:u1"
);
assert_eq!(
keys.disable("admin", "u1", "login"),
"satoken:admin:disable:login:u1"
);
assert_eq!(keys.safe("tok", "pay"), "satoken:login:safe:pay:tok");
}
#[test]
fn scan_and_parse_token() {
let keys = SaKeys::new("sa:");
assert_eq!(keys.token_scan_pattern(None), "sa:token:*");
assert_eq!(
keys.parse_token_from_key("sa:token:abc123", None),
Some("abc123")
);
assert_eq!(keys.parse_token_from_key("sa:session:u1", None), None);
let keys = SaKeys::with_layout("satoken", SaKeyLayout::JavaFourSegment);
assert_eq!(
keys.token_scan_pattern(Some("admin")),
"satoken:admin:token:*"
);
assert_eq!(
keys.parse_token_from_key("satoken:admin:token:xyz", Some("admin")),
Some("xyz")
);
}
#[test]
fn from_config_uses_storage_prefix() {
let config = SaTokenConfig::builder()
.storage_key_prefix("app:")
.build_config();
let keys = SaKeys::from_config(&config);
assert_eq!(keys.token_info("x"), "app:token:x");
}
}