monorepo/orizentic/src/core.rs

304 lines
10 KiB
Rust
Raw Normal View History

2023-09-19 22:31:30 +00:00
extern crate chrono;
extern crate jsonwebtoken as jwt;
extern crate serde;
extern crate serde_json;
extern crate uuid;
extern crate yaml_rust;
use core::chrono::prelude::*;
use core::uuid::Uuid;
use std::collections::HashMap;
use thiserror::Error;
/// Orizentic Errors
#[derive(Debug, Error)]
pub enum Error {
/// An underlying JWT decoding error. May be replaced with Orizentic semantic errors to better
/// encapsulate the JWT library.
#[error("JWT failed to decode: {0}")]
JWTError(jwt::errors::Error),
/// Token decoded and verified but was not present in the database.
#[error("Token not recognized")]
UnknownToken,
}
/// ResourceName is application-defined and names a resource to which access should be controlled
#[derive(Debug, PartialEq, Clone)]
pub struct ResourceName(pub String);
/// Permissions are application-defined descriptions of what can be done with the named resource
#[derive(Debug, PartialEq, Clone)]
pub struct Permissions(pub Vec<String>);
/// Issuers are typically informative, but should generally describe who or what created the token
#[derive(Debug, PartialEq, Clone)]
pub struct Issuer(pub String);
/// Time to live is the number of seconds until a token expires. This is used for creating tokens
/// but tokens store their actual expiration time.
#[derive(Debug, PartialEq, Clone)]
pub struct TTL(pub chrono::Duration);
/// Username, or Audience in JWT terms, should describe who or what is supposed to be using this
/// token
#[derive(Debug, PartialEq, Clone)]
pub struct Username(String);
impl From<Username> for String {
fn from(u: Username) -> String {
u.0.clone()
}
}
impl From<&str> for Username {
fn from(s: &str) -> Username {
Username(s.to_owned())
}
}
#[derive(Debug, PartialEq, Clone)]
pub struct Secret(pub Vec<u8>);
/// A ClaimSet represents one set of permissions and claims. It is a standardized way of specifying
/// the owner, issuer, expiration time, relevant resources, and specific permissions on that
/// resource. By itself, this is only an informative data structure and so should never be trusted
/// when passed over the wire. See `VerifiedToken` and `UnverifiedToken`.
#[derive(Debug, PartialEq, Clone)]
pub struct ClaimSet {
pub id: String,
pub audience: Username,
pub expiration: Option<DateTime<Utc>>,
pub issuer: Issuer,
pub issued_at: DateTime<Utc>,
pub resource: ResourceName,
pub permissions: Permissions,
}
impl ClaimSet {
/// Create a new `ClaimSet`. This will return a claimset with the expiration time calculated
/// from the TTL if the TTL is provided. No expiration will be set if no TTL is provided.
pub fn new(
issuer: Issuer,
ttl: Option<TTL>,
resource_name: ResourceName,
user_name: Username,
perms: Permissions,
) -> ClaimSet {
let issued_at: DateTime<Utc> = Utc::now().with_nanosecond(0).unwrap();
let expiration = match ttl {
Some(TTL(ttl_)) => issued_at.checked_add_signed(ttl_),
None => None,
};
ClaimSet {
id: String::from(Uuid::new_v4().to_hyphenated().to_string()),
audience: user_name,
expiration,
issuer,
issued_at,
resource: resource_name,
permissions: perms,
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(&(ClaimSetJS::from_claimset(self)))
}
pub fn from_json(text: &String) -> Result<ClaimSet, serde_json::Error> {
serde_json::from_str(&text).map(|x| ClaimSetJS::to_claimset(&x))
}
}
/// ClaimSetJS is an intermediary data structure between JWT serialization and a more usable
/// ClaimSet.
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub struct ClaimSetJS {
jti: String,
aud: String,
exp: Option<i64>,
iss: String,
iat: i64,
sub: String,
perms: Vec<String>,
}
impl ClaimSetJS {
pub fn from_claimset(claims: &ClaimSet) -> ClaimSetJS {
ClaimSetJS {
jti: claims.id.clone(),
aud: claims.audience.0.clone(),
exp: claims.expiration.map(|t| t.timestamp()),
iss: claims.issuer.0.clone(),
iat: claims.issued_at.timestamp(),
sub: claims.resource.0.clone(),
perms: claims.permissions.0.clone(),
}
}
pub fn to_claimset(&self) -> ClaimSet {
ClaimSet {
id: self.jti.clone(),
audience: Username(self.aud.clone()),
expiration: self.exp.map(|t| Utc.timestamp(t, 0)),
issuer: Issuer(self.iss.clone()),
issued_at: Utc.timestamp(self.iat, 0),
resource: ResourceName(self.sub.clone()),
permissions: Permissions(self.perms.clone()),
}
}
}
/// The Orizentic Context encapsulates a set of claims and an associated secret. This provides the
/// overall convenience of easily creating and validating tokens. Generated claimsets are stored
/// here on the theory that, even with validation, only those claims actually stored in the
/// database should be considered valid.
pub struct OrizenticCtx(Secret, HashMap<String, ClaimSet>);
/// An UnverifiedToken is a combination of the JWT serialization and the decoded `ClaimSet`. As this
/// is unverified, this should only be used for information purposes, such as determining what a
/// user can do with a token even when the decoding key is absent.
#[derive(Debug)]
pub struct UnverifiedToken {
pub text: String,
pub claims: ClaimSet,
}
impl UnverifiedToken {
/// Decode a JWT text string without verification
pub fn decode_text(text: String) -> Result<UnverifiedToken, Error> {
let res = jwt::dangerous_unsafe_decode::<ClaimSetJS>(&text);
match res {
Ok(res_) => Ok(UnverifiedToken {
text,
claims: res_.claims.to_claimset(),
}),
Err(err) => Err(Error::JWTError(err)),
}
}
}
/// An VerifiedToken is a combination of the JWT serialization and the decoded `ClaimSet`. This will
/// only be created by the `validate_function`, and thus will represent a token which has been
/// validated via signature, expiration time, and presence in the database.
#[derive(Debug)]
pub struct VerifiedToken {
pub text: String,
pub claims: ClaimSet,
}
impl VerifiedToken {
/// Given a `VerifiedToken`, pass the resource name and permissions to a user-defined function. The
/// function should return true if the caller should be granted access to the resource and false,
/// otherwise. That result will be passed back to the caller.
pub fn check_authorizations<F: FnOnce(&ResourceName, &Permissions) -> bool>(
&self,
f: F,
) -> bool {
f(&self.claims.resource, &self.claims.permissions)
}
}
impl OrizenticCtx {
/// Create a new Orizentic Context with an initial set of claims.
pub fn new(secret: Secret, claims_lst: Vec<ClaimSet>) -> OrizenticCtx {
let mut hm = HashMap::new();
for claimset in claims_lst {
hm.insert(claimset.id.clone(), claimset);
}
OrizenticCtx(secret, hm)
}
/// Validate a token by checking its signature, that it is not expired, and that it is still
/// present in the database. Return an error if any check fails, but return a `VerifiedToken`
/// if it all succeeds.
pub fn validate_token(&self, token: &UnverifiedToken) -> Result<VerifiedToken, Error> {
let validator = match token.claims.expiration {
Some(_) => jwt::Validation::default(),
None => jwt::Validation {
validate_exp: false,
..jwt::Validation::default()
},
};
let res = jwt::decode::<ClaimSetJS>(&token.text, &(self.0).0, &validator);
match res {
Ok(res_) => {
let claims = res_.claims;
let in_db = self.1.get(&claims.jti);
if in_db.is_some() {
Ok(VerifiedToken {
text: token.text.clone(),
claims: claims.to_claimset(),
})
} else {
Err(Error::UnknownToken)
}
}
Err(err) => Err(Error::JWTError(err)),
}
}
/// Given a text string, as from a web application's `Authorization` header, decode the string
/// and then validate the token.
pub fn decode_and_validate_text(&self, text: String) -> Result<VerifiedToken, Error> {
// it is necessary to first decode the token because we need the validator to know whether
// to attempt to validate the expiration. Without that check, the validator will fail any
// expiration set to None.
match UnverifiedToken::decode_text(text) {
Ok(unverified) => self.validate_token(&unverified),
Err(err) => Err(err),
}
}
/// Add a claimset to the database.
pub fn add_claimset(&mut self, claimset: ClaimSet) {
self.1.insert(claimset.id.clone(), claimset);
}
/// Remove a claims set from the database so that all additional validation checks fail.
pub fn revoke_claimset(&mut self, claim: &ClaimSet) {
self.1.remove(&claim.id);
}
/// Revoke a ClaimsSet given its ID, which is set in the `jti` claim of a JWT or the `id` field
/// of a `ClaimSet`.
pub fn revoke_by_uuid(&mut self, claim_id: &String) {
self.1.remove(claim_id);
}
/// *NOT IMPLEMENTED*
pub fn replace_claimsets(&mut self, _claims_lst: Vec<ClaimSet>) {
unimplemented!()
}
/// List all of the `ClaimSet` IDs in the database.
pub fn list_claimsets(&self) -> Vec<&ClaimSet> {
self.1.values().map(|item| item).collect()
}
/// Find a `ClaimSet` by ID.
pub fn find_claimset(&self, claims_id: &String) -> Option<&ClaimSet> {
self.1.get(claims_id)
}
/// Encode and sign a claimset, returning the result as a `VerifiedToken`.
pub fn encode_claimset(&self, claims: &ClaimSet) -> Result<VerifiedToken, Error> {
let in_db = self.1.get(&claims.id);
if in_db.is_some() {
let text = jwt::encode(
&jwt::Header::default(),
&ClaimSetJS::from_claimset(&claims),
&(self.0).0,
);
match text {
Ok(text_) => Ok(VerifiedToken {
text: text_,
claims: claims.clone(),
}),
Err(err) => Err(Error::JWTError(err)),
}
} else {
Err(Error::UnknownToken)
}
}
}