use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ScopeParseError {
#[error("empty scope token")]
Empty,
#[error("scope `{scope}` has unrecognised context — expected `patient`, `user`, or `system`")]
UnknownContext { scope: String },
#[error("scope `{scope}` has unrecognised action — expected `read`, `write`, or `*`")]
UnknownAction { scope: String },
#[error(
"scope `{0}` is malformed — expected `<context>/<resource>.<action>` or a well-known identifier"
)]
Malformed(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SmartScope {
Openid,
Profile,
FhirUser,
OfflineAccess,
Launch,
LaunchPatient,
LaunchEncounter,
Resource {
context: ScopeContext,
resource_type: ResourceFilter,
actions: ScopeActions,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScopeContext {
Patient,
User,
System,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ResourceFilter {
All,
Specific(String),
}
impl ResourceFilter {
pub fn matches(&self, resource_type: &str) -> bool {
match self {
Self::All => true,
Self::Specific(s) => s == resource_type,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ScopeActions {
pub read: bool,
pub write: bool,
}
impl ScopeActions {
pub const fn read_only() -> Self {
Self {
read: true,
write: false,
}
}
pub const fn write_only() -> Self {
Self {
read: false,
write: true,
}
}
pub const fn read_write() -> Self {
Self {
read: true,
write: true,
}
}
}
impl SmartScope {
pub fn parse(s: &str) -> Result<Self, ScopeParseError> {
let s = s.trim();
if s.is_empty() {
return Err(ScopeParseError::Empty);
}
match s {
"openid" => return Ok(Self::Openid),
"profile" => return Ok(Self::Profile),
"fhirUser" => return Ok(Self::FhirUser),
"offline_access" => return Ok(Self::OfflineAccess),
"launch" => return Ok(Self::Launch),
"launch/patient" => return Ok(Self::LaunchPatient),
"launch/encounter" => return Ok(Self::LaunchEncounter),
_ => {}
}
let (context_part, rest) = s
.split_once('/')
.ok_or_else(|| ScopeParseError::Malformed(s.to_string()))?;
let (resource_part, action_part) = rest
.split_once('.')
.ok_or_else(|| ScopeParseError::Malformed(s.to_string()))?;
let context = match context_part {
"patient" => ScopeContext::Patient,
"user" => ScopeContext::User,
"system" => ScopeContext::System,
_ => {
return Err(ScopeParseError::UnknownContext {
scope: s.to_string(),
});
}
};
let resource_type = match resource_part {
"*" => ResourceFilter::All,
other if !other.is_empty() && other.chars().all(|c| c.is_ascii_alphanumeric()) => {
ResourceFilter::Specific(other.to_string())
}
_ => return Err(ScopeParseError::Malformed(s.to_string())),
};
let actions = match action_part {
"read" => ScopeActions::read_only(),
"write" => ScopeActions::write_only(),
"*" => ScopeActions::read_write(),
_ => {
return Err(ScopeParseError::UnknownAction {
scope: s.to_string(),
});
}
};
Ok(Self::Resource {
context,
resource_type,
actions,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmartScopeSet(pub Vec<SmartScope>);
impl SmartScopeSet {
pub fn parse(s: &str) -> Result<Self, ScopeParseError> {
let mut out = Vec::new();
for tok in s.split_whitespace() {
out.push(SmartScope::parse(tok)?);
}
Ok(Self(out))
}
pub fn contains(&self, scope: &SmartScope) -> bool {
self.0.iter().any(|s| s == scope)
}
pub fn resource_scopes(&self) -> impl Iterator<Item = &SmartScope> {
self.0
.iter()
.filter(|s| matches!(s, SmartScope::Resource { .. }))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_standalone_identifiers() {
for (s, expected) in [
("openid", SmartScope::Openid),
("profile", SmartScope::Profile),
("fhirUser", SmartScope::FhirUser),
("offline_access", SmartScope::OfflineAccess),
("launch", SmartScope::Launch),
("launch/patient", SmartScope::LaunchPatient),
("launch/encounter", SmartScope::LaunchEncounter),
] {
assert_eq!(SmartScope::parse(s).unwrap(), expected);
}
}
#[test]
fn parses_patient_resource_read() {
let s = SmartScope::parse("patient/Observation.read").unwrap();
assert_eq!(
s,
SmartScope::Resource {
context: ScopeContext::Patient,
resource_type: ResourceFilter::Specific("Observation".into()),
actions: ScopeActions::read_only(),
}
);
}
#[test]
fn parses_user_wildcard_write() {
let s = SmartScope::parse("user/*.write").unwrap();
assert_eq!(
s,
SmartScope::Resource {
context: ScopeContext::User,
resource_type: ResourceFilter::All,
actions: ScopeActions::write_only(),
}
);
}
#[test]
fn star_action_grants_read_and_write() {
let s = SmartScope::parse("system/Patient.*").unwrap();
let SmartScope::Resource { actions, .. } = s else {
panic!("expected resource scope");
};
assert!(actions.read);
assert!(actions.write);
}
#[test]
fn rejects_unknown_context() {
let err = SmartScope::parse("guest/Patient.read").unwrap_err();
assert!(matches!(err, ScopeParseError::UnknownContext { .. }));
}
#[test]
fn rejects_unknown_action() {
let err = SmartScope::parse("user/Patient.execute").unwrap_err();
assert!(matches!(err, ScopeParseError::UnknownAction { .. }));
}
#[test]
fn rejects_malformed() {
for s in ["", "garbage", "user/", "/Patient.read", "user.read"] {
assert!(SmartScope::parse(s).is_err(), "{s:?} should not parse");
}
}
#[test]
fn scope_set_parses_space_separated() {
let set = SmartScopeSet::parse(
"openid profile fhirUser launch/patient patient/*.read patient/Observation.write",
)
.unwrap();
assert_eq!(set.0.len(), 6);
assert!(set.contains(&SmartScope::Openid));
assert!(set.contains(&SmartScope::LaunchPatient));
}
#[test]
fn scope_set_resource_iter_filters_correctly() {
let set = SmartScopeSet::parse("openid patient/*.read user/Patient.write").unwrap();
assert_eq!(set.resource_scopes().count(), 2);
}
#[test]
fn resource_filter_matches_specific_or_all() {
assert!(ResourceFilter::All.matches("Patient"));
assert!(ResourceFilter::All.matches("Observation"));
assert!(ResourceFilter::Specific("Patient".into()).matches("Patient"));
assert!(!ResourceFilter::Specific("Patient".into()).matches("Observation"));
}
#[test]
fn extra_whitespace_in_set_is_tolerated() {
let set = SmartScopeSet::parse(" openid profile \n fhirUser ").unwrap();
assert_eq!(set.0.len(), 3);
}
}