use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
const DEFAULT_BASE: &str = "https://getlocksmith.dev";
const ISSUER: &str = "https://getlocksmith.dev";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Environment {
Production,
Sandbox,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("locksmith: {code} ({status}): {message}")]
Api {
code: String,
message: String,
status: u16,
},
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("invalid API key: must start with lsm_live_ or lsm_sbx_")]
InvalidApiKey,
#[error("invalid response: {0}")]
InvalidResponse(String),
#[error("jwt: {0}")]
Jwt(#[from] jsonwebtoken::errors::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
pub fn environment_from_api_key(api_key: &str) -> Result<Environment> {
if api_key.starts_with("lsm_live_") {
Ok(Environment::Production)
} else if api_key.starts_with("lsm_sbx_") {
Ok(Environment::Sandbox)
} else {
Err(Error::InvalidApiKey)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserBase {
pub id: String,
pub email: String,
pub role: String,
pub meta: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserSignup {
#[serde(flatten)]
pub base: UserBase,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserLogin {
#[serde(flatten)]
pub base: UserBase,
pub last_login_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserMe {
#[serde(flatten)]
pub base: UserBase,
pub email_verified: bool,
pub two_factor_enabled: bool,
pub passkey_count: i64,
pub roles: Vec<String>,
pub permissions: Vec<String>,
pub created_at: String,
pub last_login_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthTokens {
pub access_token: String,
pub refresh_token: String,
pub expires_in: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignUpResult {
pub user: UserSignup,
#[serde(flatten)]
pub tokens: AuthTokens,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignInResult {
pub user: UserLogin,
#[serde(flatten)]
pub tokens: AuthTokens,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MagicLinkVerifyResult {
pub user: UserSignup,
#[serde(flatten)]
pub tokens: AuthTokens,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OAuthInitiateResult {
pub provider: String,
pub authorization_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OAuthExchangeUser {
pub id: String,
pub email: String,
pub role: String,
pub meta: HashMap<String, serde_json::Value>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OAuthTokenExchangeResult {
pub user: OAuthExchangeUser,
#[serde(flatten)]
pub tokens: AuthTokens,
pub provider: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OidcGrantResult {
pub redirect_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenPayload {
pub sub: String,
pub email: String,
pub role: String,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub permissions: Vec<String>,
pub environment: Environment,
pub meta: HashMap<String, serde_json::Value>,
pub aud: String,
pub iss: String,
pub iat: i64,
pub exp: i64,
}
pub fn token_has_role(t: &TokenPayload, role: &str) -> bool {
t.roles.iter().any(|r| r == role)
}
pub fn token_has_permission(t: &TokenPayload, permission: &str) -> bool {
t.permissions.iter().any(|p| p == permission)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionCreateInput {
pub key: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
#[derive(Deserialize)]
struct Envelope<T> {
data: T,
}
#[derive(Deserialize)]
struct ApiErrBody {
error: Option<String>,
message: Option<String>,
}
pub struct LocksmithClient {
api_key: String,
base_url: String,
http: reqwest::Client,
pub environment: Environment,
}
impl LocksmithClient {
pub fn new(api_key: impl Into<String>, base_url: Option<&str>) -> Result<Self> {
let api_key = api_key.into();
let environment = environment_from_api_key(&api_key)?;
let base = base_url
.unwrap_or(DEFAULT_BASE)
.trim_end_matches('/')
.to_string();
Ok(Self {
api_key,
base_url: base,
http: reqwest::Client::builder()
.use_rustls_tls()
.build()?,
environment,
})
}
fn url(&self, path: &str) -> String {
let p = if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
};
format!("{}{p}", self.base_url)
}
async fn request_json<T: DeserializeOwned>(
&self,
method: reqwest::Method,
path: &str,
body: Option<serde_json::Value>,
extra_headers: Option<Vec<(&str, String)>>,
) -> Result<T> {
let mut req = self
.http
.request(method, self.url(path))
.header("X-API-Key", &self.api_key);
if let Some(h) = extra_headers {
for (k, v) in h {
req = req.header(k, v);
}
}
if let Some(b) = body {
req = req
.header("Content-Type", "application/json")
.json(&b);
}
let res = req.send().await?;
self.parse_envelope(res).await
}
async fn parse_envelope<T: DeserializeOwned>(&self, res: reqwest::Response) -> Result<T> {
let status = res.status();
let text = res.text().await.unwrap_or_default();
let v: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::json!({}));
if !status.is_success() {
let err: ApiErrBody = serde_json::from_value(v.clone()).unwrap_or(ApiErrBody {
error: None,
message: None,
});
return Err(Error::Api {
code: err.error.unwrap_or_else(|| "unknown_error".into()),
message: err
.message
.unwrap_or_else(|| status.canonical_reason().unwrap_or("error").into()),
status: status.as_u16(),
});
}
let envelope: Envelope<T> =
serde_json::from_value(v).map_err(|e| Error::InvalidResponse(e.to_string()))?;
Ok(envelope.data)
}
pub async fn sign_up(
&self,
email: &str,
password: &str,
meta: Option<serde_json::Value>,
) -> Result<SignUpResult> {
let mut body = serde_json::json!({ "email": email, "password": password });
if let Some(m) = meta {
body["meta"] = m;
}
self.request_json(reqwest::Method::POST, "/api/auth/signup", Some(body), None)
.await
}
pub async fn sign_in(&self, email: &str, password: &str) -> Result<SignInResult> {
let body = serde_json::json!({ "email": email, "password": password });
self.request_json(reqwest::Method::POST, "/api/auth/login", Some(body), None)
.await
}
pub async fn sign_out(&self, refresh_token: &str) -> Result<()> {
let body = serde_json::json!({ "refreshToken": refresh_token });
let _v: serde_json::Value = self
.request_json(reqwest::Method::POST, "/api/auth/logout", Some(body), None)
.await?;
Ok(())
}
pub async fn refresh(&self, refresh_token: &str) -> Result<AuthTokens> {
let body = serde_json::json!({ "refreshToken": refresh_token });
self.request_json(reqwest::Method::POST, "/api/auth/refresh", Some(body), None)
.await
}
pub async fn get_user(&self, access_token: &str) -> Result<UserMe> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Me {
user: UserMe,
}
let me: Me = self
.request_json(
reqwest::Method::GET,
"/api/auth/me",
None,
Some(vec![(
"Authorization",
format!("Bearer {access_token}"),
)]),
)
.await?;
Ok(me.user)
}
pub fn verify_token(access_token: &str, public_key_pem: &str) -> Result<TokenPayload> {
let key = DecodingKey::from_rsa_pem(public_key_pem.as_bytes())?;
let mut val = Validation::new(Algorithm::RS256);
val.set_issuer(&[ISSUER]);
let t = decode::<TokenPayload>(access_token, &key, &val)?;
Ok(t.claims)
}
pub async fn send_magic_link(
&self,
email: &str,
create_if_not_exists: Option<bool>,
) -> Result<()> {
let mut body = serde_json::json!({ "email": email });
if let Some(b) = create_if_not_exists {
body["createIfNotExists"] = serde_json::json!(b);
}
let _v: serde_json::Value = self
.request_json(
reqwest::Method::POST,
"/api/auth/magic-link",
Some(body),
None,
)
.await?;
Ok(())
}
pub async fn verify_magic_link(
&self,
token: &str,
project_id: &str,
) -> Result<MagicLinkVerifyResult> {
let res = self
.http
.get(self.url("/api/auth/magic-link/verify"))
.query(&[("token", token), ("project", project_id)])
.send()
.await?;
self.parse_envelope(res).await
}
pub async fn send_password_reset(&self, email: &str) -> Result<()> {
let body = serde_json::json!({ "email": email });
let _v: serde_json::Value = self
.request_json(
reqwest::Method::POST,
"/api/auth/password/reset",
Some(body),
None,
)
.await?;
Ok(())
}
pub async fn update_password(&self, token: &str, new_password: &str) -> Result<()> {
let body = serde_json::json!({ "token": token, "newPassword": new_password });
let _v: serde_json::Value = self
.request_json(
reqwest::Method::POST,
"/api/auth/password/update",
Some(body),
None,
)
.await?;
Ok(())
}
pub async fn initiate_oauth(
&self,
provider: &str,
redirect_url: Option<&str>,
) -> Result<OAuthInitiateResult> {
let path = format!(
"/api/auth/oauth/{}",
urlencoding::encode(provider)
);
let mut body = serde_json::Value::Object(serde_json::Map::new());
if let Some(u) = redirect_url.filter(|s| !s.is_empty()) {
body["redirectUrl"] = serde_json::Value::String(u.to_string());
}
self.request_json(
reqwest::Method::POST,
&path,
Some(body),
None,
)
.await
}
pub async fn exchange_oauth_code(&self, code: &str) -> Result<OAuthTokenExchangeResult> {
let body = serde_json::json!({ "code": code });
self.request_json(reqwest::Method::POST, "/api/auth/oauth/token", Some(body), None)
.await
}
pub async fn complete_oidc_grant(
&self,
request_token: &str,
approved: bool,
user_id: Option<&str>,
scopes: Option<&[String]>,
) -> Result<OidcGrantResult> {
let mut body = serde_json::json!({
"requestToken": request_token,
"approved": approved,
});
if let Some(uid) = user_id {
body["userId"] = serde_json::Value::String(uid.to_string());
}
if let Some(s) = scopes {
if !s.is_empty() {
body["scopes"] = serde_json::to_value(s).unwrap_or(serde_json::json!([]));
}
}
self.request_json(
reqwest::Method::POST,
"/api/auth/oidc/grant",
Some(body),
None,
)
.await
}
pub async fn rbac_list_roles(&self) -> Result<Vec<serde_json::Value>> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct W {
roles: Vec<serde_json::Value>,
}
let w: W = self
.request_json(reqwest::Method::GET, "/api/auth/rbac/roles", None, None)
.await?;
Ok(w.roles)
}
pub async fn rbac_get_role(&self, role_id: &str) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct W {
role: serde_json::Value,
}
let path = format!("/api/auth/rbac/roles/{}", urlencoding::encode(role_id));
let w: W = self.request_json(reqwest::Method::GET, &path, None, None).await?;
Ok(w.role)
}
pub async fn rbac_create_role(&self, body: serde_json::Value) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct W {
role: serde_json::Value,
}
let w: W = self
.request_json(reqwest::Method::POST, "/api/auth/rbac/roles", Some(body), None)
.await?;
Ok(w.role)
}
pub async fn rbac_update_role(
&self,
role_id: &str,
patch: serde_json::Value,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct W {
role: serde_json::Value,
}
let path = format!("/api/auth/rbac/roles/{}", urlencoding::encode(role_id));
let w: W = self
.request_json(reqwest::Method::PATCH, &path, Some(patch), None)
.await?;
Ok(w.role)
}
pub async fn rbac_delete_role(&self, role_id: &str) -> Result<()> {
let path = format!("/api/auth/rbac/roles/{}", urlencoding::encode(role_id));
let _v: serde_json::Value = self
.request_json(reqwest::Method::DELETE, &path, None, None)
.await?;
Ok(())
}
pub async fn rbac_set_role_permissions(
&self,
role_id: &str,
permission_ids: Vec<String>,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct W {
role: serde_json::Value,
}
let path = format!(
"/api/auth/rbac/roles/{}/permissions",
urlencoding::encode(role_id)
);
let body = serde_json::json!({ "permissionIds": permission_ids });
let w: W = self
.request_json(reqwest::Method::PUT, &path, Some(body), None)
.await?;
Ok(w.role)
}
pub async fn rbac_list_permissions(&self) -> Result<Vec<serde_json::Value>> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct W {
permissions: Vec<serde_json::Value>,
}
let w: W = self
.request_json(reqwest::Method::GET, "/api/auth/rbac/permissions", None, None)
.await?;
Ok(w.permissions)
}
pub async fn rbac_get_permission(&self, permission_id: &str) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct W {
permission: serde_json::Value,
}
let path = format!(
"/api/auth/rbac/permissions/{}",
urlencoding::encode(permission_id)
);
let w: W = self.request_json(reqwest::Method::GET, &path, None, None).await?;
Ok(w.permission)
}
pub async fn rbac_create_permission(&self, body: serde_json::Value) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct W {
permission: serde_json::Value,
}
let w: W = self
.request_json(
reqwest::Method::POST,
"/api/auth/rbac/permissions",
Some(body),
None,
)
.await?;
Ok(w.permission)
}
pub async fn rbac_create_permissions_bulk(
&self,
permissions: Vec<PermissionCreateInput>,
) -> Result<Vec<serde_json::Value>> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct W {
permissions: Vec<serde_json::Value>,
}
let body = serde_json::json!({ "permissions": permissions });
let w: W = self
.request_json(
reqwest::Method::POST,
"/api/auth/rbac/permissions/bulk",
Some(body),
None,
)
.await?;
Ok(w.permissions)
}
pub async fn rbac_update_permission(
&self,
permission_id: &str,
patch: serde_json::Value,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct W {
permission: serde_json::Value,
}
let path = format!(
"/api/auth/rbac/permissions/{}",
urlencoding::encode(permission_id)
);
let w: W = self
.request_json(reqwest::Method::PATCH, &path, Some(patch), None)
.await?;
Ok(w.permission)
}
pub async fn rbac_delete_permission(&self, permission_id: &str) -> Result<()> {
let path = format!(
"/api/auth/rbac/permissions/{}",
urlencoding::encode(permission_id)
);
let _v: serde_json::Value = self
.request_json(reqwest::Method::DELETE, &path, None, None)
.await?;
Ok(())
}
pub async fn rbac_get_user_roles(&self, user_id: &str) -> Result<Vec<serde_json::Value>> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct W {
assignments: Vec<serde_json::Value>,
}
let path = format!(
"/api/auth/rbac/users/{}/roles",
urlencoding::encode(user_id)
);
let w: W = self.request_json(reqwest::Method::GET, &path, None, None).await?;
Ok(w.assignments)
}
pub async fn rbac_assign_role(&self, user_id: &str, role_id: &str) -> Result<()> {
let path = format!(
"/api/auth/rbac/users/{}/roles/{}",
urlencoding::encode(user_id),
urlencoding::encode(role_id)
);
let _v: serde_json::Value = self
.request_json(reqwest::Method::POST, &path, None, None)
.await?;
Ok(())
}
pub async fn rbac_revoke_role(&self, user_id: &str, role_id: &str) -> Result<()> {
let path = format!(
"/api/auth/rbac/users/{}/roles/{}",
urlencoding::encode(user_id),
urlencoding::encode(role_id)
);
let _v: serde_json::Value = self
.request_json(reqwest::Method::DELETE, &path, None, None)
.await?;
Ok(())
}
pub async fn rbac_set_user_roles(
&self,
user_id: &str,
role_ids: Vec<String>,
) -> Result<Vec<serde_json::Value>> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct W {
roles: Vec<serde_json::Value>,
}
let path = format!(
"/api/auth/rbac/users/{}/roles",
urlencoding::encode(user_id)
);
let body = serde_json::json!({ "roleIds": role_ids });
let w: W = self
.request_json(reqwest::Method::PUT, &path, Some(body), None)
.await?;
Ok(w.roles)
}
}