aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGalen Guyer <galen@galenguyer.com>2022-07-18 13:38:38 -0400
committerGalen Guyer <galen@galenguyer.com>2022-07-18 14:09:04 -0400
commit80966952450b039b389a866a3d4ece321dde9e14 (patch)
tree0a323970deb7934c1e20a81dd374f6bba6ecf0b9
Initial library with reqwest and ureq features
-rw-r--r--.github/workflows/checks.yaml32
-rw-r--r--.gitignore2
-rw-r--r--Cargo.toml29
-rw-r--r--LICENSE7
-rw-r--r--Makefile.toml54
-rw-r--r--README.md7
-rw-r--r--src/client.rs9
-rw-r--r--src/client/reqwest.rs76
-rw-r--r--src/client/ureq.rs76
-rw-r--r--src/lib.rs40
-rw-r--r--src/user.rs44
11 files changed, 376 insertions, 0 deletions
diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml
new file mode 100644
index 0000000..36d2821
--- /dev/null
+++ b/.github/workflows/checks.yaml
@@ -0,0 +1,32 @@
+name: Code quality checks
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ shared:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: Ensure no default features are set
+ run: grep -qP 'default = \[\]' Cargo.toml
+ - name: Ensure formatting is correct
+ run: cargo fmt --check
+
+ features:
+ strategy:
+ matrix:
+ feature: ["ureq", "reqwest"]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: Run clippy with feature ${{ matrix.feature }}
+ run: cargo clippy --features=${{ matrix.feature }}
+ - name: Run tests with feature ${{ matrix.feature }}
+ run: cargo test --features=${{ matrix.feature }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4fffb2f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+/Cargo.lock
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..0a4c976
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "oidc-rs"
+authors = ["Galen Guyer <galen@galenguyer.com"]
+description = "A generic OIDC client"
+version = "0.1.0"
+edition = "2021"
+license = "MIT"
+repository = "https://github.com/galenguyer/oidc-rs"
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+itertools = "0.10.3"
+serde = { version = "1.0.139", features = ["derive"] }
+cfg-if = "1.0.0"
+
+[dependencies.reqwest]
+version = "0.11.11"
+features = ["json"]
+optional = true
+
+[dependencies.ureq]
+version = "2.5.0"
+features = ["json"]
+optional = true
+
+[features]
+default = []
+ureq = ["dep:ureq"]
+reqwest = ["dep:reqwest"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..471a8ec
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,7 @@
+Copyright © 2022 Galen Guyer <galen@galenguyer.com>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/Makefile.toml b/Makefile.toml
new file mode 100644
index 0000000..7eb9059
--- /dev/null
+++ b/Makefile.toml
@@ -0,0 +1,54 @@
+[tasks.ureq-tests]
+command = "cargo"
+args = ["test", "--quiet", "--features=ureq"]
+
+[tasks.reqwest-tests]
+command = "cargo"
+args = ["test", "--quiet", "--features=reqwest"]
+
+[tasks.test]
+clear = true
+run_task = [
+ { name = ["ureq-tests", "reqwest-tests"] }
+]
+
+
+[tasks.ureq-clippy]
+command = "cargo"
+args = ["clippy", "--quiet", "--features=ureq"]
+
+[tasks.reqwest-clippy]
+command = "cargo"
+args = ["clippy", "--quiet", "--features=reqwest"]
+
+[tasks.clippy]
+clear = true
+run_task = [
+ { name = ["ureq-clippy", "reqwest-clippy"] }
+]
+
+
+[tasks.check-format]
+command = "cargo"
+args = ["fmt", "--check"]
+
+
+[tasks.ensure-no-defaults]
+command = "grep"
+args = ["-qP", "default = \\[\\]", "Cargo.toml"]
+
+
+[tasks.check]
+clear = true
+run_task = [
+ { name = ["check-format", "ensure-no-defaults", "clippy", "test"] },
+]
+
+
+[tasks.publish]
+clear = true
+dependencies = [
+ "check"
+]
+command = "cargo"
+args = ["publish"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..abf16fa
--- /dev/null
+++ b/README.md
@@ -0,0 +1,7 @@
+# oidc-rs
+Hopefully simple OIDC interactions. Designed for Computer Science House but hopefully useful for other people as well.
+
+Currently, valid HTTPS is required for any OIDC requests. Insecure connections may be added behind a feature gate.
+
+## Configuration
+The only configuration value is the base path for OIDC lookups. This can either be passed into `OidcClient::new(base_path: &str)` or provided in the environment as `OIDC_BASE_PATH`. The format of this should be akin to `https://sso.example.com/auth/realms/master`. Failure to set this will result in a panic at runtime.
diff --git a/src/client.rs b/src/client.rs
new file mode 100644
index 0000000..03fee30
--- /dev/null
+++ b/src/client.rs
@@ -0,0 +1,9 @@
+#[cfg(feature = "reqwest")]
+mod reqwest;
+#[cfg(feature = "reqwest")]
+pub use self::reqwest::OIDCClient;
+
+#[cfg(feature = "ureq")]
+mod ureq;
+#[cfg(feature = "ureq")]
+pub use self::ureq::OIDCClient;
diff --git a/src/client/reqwest.rs b/src/client/reqwest.rs
new file mode 100644
index 0000000..b75458e
--- /dev/null
+++ b/src/client/reqwest.rs
@@ -0,0 +1,76 @@
+use crate::{user::OIDCUser, OIDCError};
+use std::{env, time::Duration};
+
+#[derive(Clone)]
+pub struct OIDCClient {
+ /// Re-usable HTTP client for making requests
+ http_client: reqwest::Client,
+ /// The base path for OIDC requests. Should be in the format of `https://sso.example.com/auth/realms/master`.
+ base_path: String,
+}
+
+impl OIDCClient {
+ /// Takes the base path in the format of `https://sso.example.com/auth/realms/master` and returns a re-usable client
+ #[must_use]
+ pub fn new(base_path: &str) -> Self {
+ OIDCClient {
+ http_client: reqwest::ClientBuilder::new()
+ .user_agent(&format!("oidc-rs/{}", env!("CARGO_PKG_VERSION")))
+ .timeout(Duration::from_secs(5))
+ .https_only(true)
+ .build()
+ .expect("Building reqwest client failed"),
+ base_path: base_path.to_owned(),
+ }
+ }
+
+ pub async fn validate_token(&self, token: &str) -> Result<OIDCUser, OIDCError> {
+ let formatted_token = if token.starts_with("Bearer") {
+ token.to_string()
+ } else {
+ format!("Bearer {token}")
+ };
+
+ let res = self
+ .http_client
+ .get(&format!(
+ "{}/protocol/openid-connect/userinfo",
+ self.base_path
+ ))
+ .header("Authorization", &formatted_token)
+ .send()
+ .await;
+
+ match res {
+ Ok(response) => {
+ if response.status().is_success() {
+ match response.json::<OIDCUser>().await {
+ Ok(user) => Ok(user),
+ Err(e) => Err(OIDCError::ReqwestError(Box::new(e))),
+ }
+ } else if response.status().is_client_error() {
+ Err(OIDCError::Unauthorized)
+ } else {
+ Err(OIDCError::Unknown)
+ }
+ }
+ Err(e) => Err(OIDCError::ReqwestError(Box::new(e))),
+ }
+ }
+}
+
+impl Default for OIDCClient {
+ fn default() -> Self {
+ Self::new(&env::var("OIDC_BASE_PATH").expect("OIDC_BASE_PATH is not set"))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::OIDCClient;
+
+ #[test]
+ fn test_create_client() {
+ let _ = OIDCClient::new("https://sso.csh.rit.edu/auth/realms/csh");
+ }
+}
diff --git a/src/client/ureq.rs b/src/client/ureq.rs
new file mode 100644
index 0000000..f6d59d4
--- /dev/null
+++ b/src/client/ureq.rs
@@ -0,0 +1,76 @@
+use crate::{user::OIDCUser, OIDCError};
+use std::{env, time::Duration};
+
+#[derive(Clone)]
+pub struct OIDCClient {
+ /// Re-usable HTTP client for making requests
+ http_client: ureq::Agent,
+ /// The base path for OIDC requests. Should be in the format of `https://sso.example.com/auth/realms/master`.
+ base_path: String,
+}
+
+impl OIDCClient {
+ /// Takes the base path in the format of `https://sso.example.com/auth/realms/master` and returns a re-usable client
+ #[must_use]
+ pub fn new(base_path: &str) -> Self {
+ OIDCClient {
+ http_client: ureq::AgentBuilder::new()
+ .timeout_connect(Duration::from_secs(5))
+ .timeout_read(Duration::from_secs(5))
+ .user_agent(&format!("oidc-rs/{}", env!("CARGO_PKG_VERSION")))
+ .https_only(true)
+ .build(),
+ base_path: base_path.to_owned(),
+ }
+ }
+
+ pub fn validate_token(&self, token: &str) -> Result<OIDCUser, OIDCError> {
+ let formatted_token = if token.starts_with("Bearer") {
+ token.to_string()
+ } else {
+ format!("Bearer {token}")
+ };
+
+ let res = self
+ .http_client
+ .get(&format!(
+ "{}/protocol/openid-connect/userinfo",
+ self.base_path
+ ))
+ .set("Authorization", &formatted_token)
+ .call();
+
+ match res {
+ Ok(response) => {
+ if response.status() >= 200 && response.status() <= 300 {
+ match response.into_json::<OIDCUser>() {
+ Ok(user) => Ok(user),
+ // TODO: need a better error here... but ureq and reqwest do different types
+ Err(_e) => Err(OIDCError::Unknown),
+ }
+ } else if response.status() == 401 || response.status() == 403 {
+ Err(OIDCError::Unauthorized)
+ } else {
+ Err(OIDCError::Unknown)
+ }
+ }
+ Err(e) => Err(OIDCError::UreqError(Box::new(e))),
+ }
+ }
+}
+
+impl Default for OIDCClient {
+ fn default() -> Self {
+ Self::new(&env::var("OIDC_BASE_PATH").expect("OIDC_BASE_PATH is not set"))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::OIDCClient;
+
+ #[test]
+ fn test_create_client() {
+ let _ = OIDCClient::new("https://sso.csh.rit.edu/auth/realms/csh");
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..984dd7e
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,40 @@
+cfg_if::cfg_if! {
+ if #[cfg(all(feature = "ureq", feature = "reqwest"))] {
+ compile_error!("Features \"ureq\" and \"reqwest\" are incompatible. Please select only one");
+ } else if #[cfg(not(any(feature = "ureq", feature = "reqwest")))] {
+ compile_error!("An HTTP backend must be specified (one of \"ureq\", \"reqwest\")");
+ }
+}
+
+#[forbid(unsafe_code)]
+mod client;
+pub use client::OIDCClient;
+
+pub mod user;
+
+use std::error::Error;
+
+#[derive(Debug)]
+pub enum OIDCError {
+ Unauthorized,
+ #[cfg(feature = "ureq")]
+ UreqError(Box<ureq::Error>),
+ #[cfg(feature = "reqwest")]
+ ReqwestError(Box<reqwest::Error>),
+ Unknown,
+}
+
+impl Error for OIDCError {}
+
+impl std::fmt::Display for OIDCError {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ OIDCError::Unauthorized => write!(f, "OIDC returned Unauthorized"),
+ #[cfg(feature = "ureq")]
+ OIDCError::UreqError(ue) => write!(f, "Ureq Error: {ue}"),
+ #[cfg(feature = "reqwest")]
+ OIDCError::ReqwestError(re) => write!(f, "Reqwest Error: {re}"),
+ &OIDCError::Unknown => write!(f, "Unknown OIDC Error"),
+ }
+ }
+}
diff --git a/src/user.rs b/src/user.rs
new file mode 100644
index 0000000..c6283e4
--- /dev/null
+++ b/src/user.rs
@@ -0,0 +1,44 @@
+use itertools::Itertools;
+use serde::{Deserialize, Serialize};
+
+/// A very basic struct of information we can get from OIDC
+#[derive(Debug, Serialize, Deserialize)]
+pub struct OIDCUser {
+ /// The real name of a user
+ pub name: Option<String>,
+ /// The username of a user
+ pub preferred_username: String,
+ /// Any groups the user is in
+ pub groups: Box<[String]>,
+ // TODO: A variable list of any other attributes we're given
+}
+
+impl OIDCUser {
+ #[must_use]
+ pub fn has_group(&self, group_name: &str) -> bool {
+ self.groups.iter().contains(&group_name.to_owned())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn user_has_group() {
+ let user = super::OIDCUser {
+ name: Some(String::from("Testy McTestyFace")),
+ preferred_username: String::from("test"),
+ groups: Box::new([String::from("member")]),
+ };
+ assert_eq!(user.has_group("member"), true);
+ }
+
+ #[test]
+ fn user_missing_group() {
+ let user = super::OIDCUser {
+ name: Some(String::from("Testy McTestyFace")),
+ preferred_username: String::from("test"),
+ groups: Box::new([]),
+ };
+ assert_eq!(user.has_group("missing"), false);
+ }
+}