diff --git a/Cargo.lock b/Cargo.lock index e230e29..5c491ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,8 +82,9 @@ dependencies = [ "ring", "serde", "serde_json", + "serde_yaml", "tide", - "toml", + "tide-serve-dir-macro", ] [[package]] @@ -1382,6 +1383,15 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "semver" version = "0.9.0" @@ -1448,15 +1458,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "serde_spanned" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1469,6 +1470,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.6.1" @@ -1738,6 +1752,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tide-serve-dir-macro" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0569876d14d685e8047bb24104c2e5c36768507cb0e3bc719b962c10d08c6e8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "walkdir", +] + [[package]] name = "time" version = "0.2.27" @@ -1791,40 +1817,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "toml" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "winnow", -] - [[package]] name = "tracing" version = "0.1.40" @@ -1878,6 +1870,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -1944,6 +1942,16 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c4517f54858c779bbcbf228f4fca63d121bf85fbecb2dc578cdf4a39395690" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" @@ -2050,6 +2058,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2196,12 +2213,3 @@ name = "windows_x86_64_msvc" version = "0.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0770833d60a970638e989b3fa9fd2bb1aaadcf88963d1659fd7d9990196ed2d6" - -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] diff --git a/Cargo.toml b/Cargo.toml index 1ef894f..fc5de3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,9 @@ async-std = { version = "1.8.0", features = ["attributes"] } tide = { version = "0.16.0", features = [] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_yaml = "0.9.34" ring = { version = "0.17.8", features = ["std"] } anyhow = "1.0.70" base64 = "0.21.0" -toml = "0.7.3" hex = "0.4.3" +tide-serve-dir-macro = "0.1" diff --git a/README.md b/README.md index 540079e..45168b2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# ^NON +# ^NON \[anon\] -Extremely rudimentary OIDC provider. Users hold account numbers from which +Extremely rudimentary OIDC provider. Users hold account codes from which their identities are derived on-demand. Each identity is separate for different services, but can be accessed from a -single account number. ^NON does not have a database of the users, so nobody +single account code. ^NON does not have a database of the users, so nobody can correlate user information across services. ## Installation @@ -14,4 +14,21 @@ can correlate user information across services. 2. fill out `config.toml.sample`. The server expects a file called `config.toml` in its working directory. -3. Enjoy :) +3. Generate the keypair for signing JWT tokens with: + ```bash + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -pkeyopt rsa_keygen_pubexp:65537 | \ + openssl pkcs8 -topk8 -nocrypt -outform der > rsa-key.pk8 + ``` + +4. Enjoy :) + +## Deployment notes + +When deploying, you should be aware of the potential of a birthday attack on +the system. For `v1` of the account code, we should expect a collision after +about `2^36` unique accounts, which means that, without rate-limiting, there is +the potential to brute-force an account / accidentally log into someone else's +account. You should consider the amount of users which will use the system, and +set up a rate-limiter. + +Improbable things happen all the time, so better safe than sorry :) diff --git a/src/accounts.rs b/src/accounts.rs new file mode 100644 index 0000000..1c66307 --- /dev/null +++ b/src/accounts.rs @@ -0,0 +1,165 @@ +/// v1 of the account identificators +use std::time::{SystemTime, UNIX_EPOCH}; + +use ring::rand::{SecureRandom, SystemRandom}; +use serde_json::{json, Value}; + +const ADJECTIVES: &[&'static str] = &[ + "Affinity", "Artisan", "Benevolent", "Breezy", "Cheerful", "Compass", + "Curious", "Eager", "Ebullient", "Effervescent", "Esteem", "Evergreen", + "Flourish", "Genuine", "Grounded", "Harmony", "Inventive", "Journey", + "Jubilant", "Kaleidoscope", "Kaleidoscopic", "Kindred", "Marvel", "Melodious", + "Optimistic", "Resilient", "Riverstone", "Serendipitous", "Serendipity", "Serene", + "Sincere", "Skybound", "Sparkling", "Sprightly", "Stardust", "Stargazer", + "Starry", "Steadfast", "Sunbeam", "Tapestry", "Tenacious", "Upbeat", + "Valiant", "Verdant", "Vigorous", "Wanderlust", "Whimsical", "Witty", + "Wonderstruck", "Zephyr", +]; + +const ANIMALS: &[&'static str] = &[ + "Axolotl", "Badger", "Baku", "Butterfly", "Capybara", "Carbuncle", + "Chameleon", "Cheetah", "Deer", "Dolphin", "Dryad", "Echidna", + "Elephant", "Fennec Fox", "Flamingo", "Fox", "Fu", "Gnome", + "Hippocampus", "Hippogriff", "Hippopotamus", "Hummingbird", "Iguana", "Kangaroo", + "Kelpie", "Kirin", "Koala", "Dragon", "Leviathan", "Lion", + "Lynx", "Manatee", "Manok", "Meerkat", "Mooncalf", "Naiad", + "Narwhal", "Orangutan", "Owl", "Panda", "Parrot", "Peacock", + "Penguin", "Phoenix", "Pixie", "Qilin", "Quokka", "Raccoon", + "Otter", "Seahorse", "Serpent", "Simurgh", "Sphynx", "Sprite", + "Squirrel", "Sylph", "Tapir", "Toucan", "Turtle", "Unicorn", + "Whale", "Ziz", +]; + +fn checksum(ns: &[u8]) -> u8 { + (ns.iter().enumerate().fold(0, |acc, b| { + if b.0 % 2 == 0 { + acc + *b.1 as i32 * 2 + } else { + acc + *b.1 as i32 + } + }) % 36) as u8 +} + +pub fn normalize(account: &str) -> Option { + // we interpret the account "number" in ASCII + let account_low = account.to_lowercase(); + + if account_low.chars().next() != Some('a') { + return None; + } + + let ns: Vec = account_low + .bytes() + .filter_map(|b| { + // base-36 + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'z' => Some(b - b'a' + 10), + _ => None, + } + }) + .collect(); + + if ns.len() != 16 { + return None; + } + + // Calculate the checksum (modified Luhn's algorithm) + let checksum = checksum(&ns[..ns.len() - 1]); + + if *ns.last().unwrap() != checksum { + return None; + } + + Some(account_low) +} + +pub fn generate_account() -> anyhow::Result { + let mut code = Vec::with_capacity(16); + code.push(10); // 'a' + + let rng = SystemRandom::new(); + 'outer: loop { + let mut bytes = [0u8; 14]; + rng.fill(&mut bytes)?; + + for byte in bytes { + if byte > u8::MAX - u8::MAX % 36 { + continue; + } + + code.push(byte % 36); + + if code.len() == 15 { + break 'outer; + } + } + } + + code.push(checksum(&code)); + + Ok(String::from_utf8( + code.iter() + .map(|n| match n { + 0..=9 => b'0' + n, + 10..=35 => b'a' + n - 10, + _ => unreachable!(), + }) + .collect(), + )? + .to_uppercase()) +} + +pub fn create_id_token_claims( + salt: &str, + issuer_uri: &str, + client_id: &str, + normalized_account: &str, + nonce: Option<&str>, +) -> Value { + // we can expect that + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("no time travelling allowed before 1970") + .as_secs(); + + use ring::hmac; + let mangler = hmac::Key::new( + hmac::HMAC_SHA256, + (salt.to_owned() + &normalized_account).as_bytes(), + ); + + let mangle = |what: &str| { + let tag = hmac::sign(&mangler, (what.to_owned() + client_id).as_bytes()); + hex::encode(tag.as_ref()) + }; + let choose = |what: &str, from: &[&'static str]| { + let tag = hmac::sign(&mangler, (what.to_owned() + client_id).as_bytes()); + let mut bytes: [u8; 4] = [0; 4]; + bytes.copy_from_slice(tag.as_ref()); + from[i32::from_le_bytes(bytes) as usize % from.len()] + }; + + let adjective = choose("family_name", ADJECTIVES); + let animal = choose("given_name", ANIMALS); + + let mut body = json!({ + "kid": "master", + "iss": issuer_uri, + "sub": mangle("sub")[..32], + "aud": client_id, + "exp": now + crate::TOKEN_EXPIRATION, + "iat": now, + "given_name": animal, + "family_name": adjective, + "email": mangle("email")[..24].to_owned()+"@email.invalid", + "email_verified": false, + "preferred_username": adjective.to_owned()+animal, + }); + + if let Some(nonce) = nonce { + body["nonce"] = nonce.into(); + } + + body +} diff --git a/src/authorization.html b/src/authorization.html index 02f120f..41395c3 100644 --- a/src/authorization.html +++ b/src/authorization.html @@ -4,10 +4,10 @@ - + {{issuer_name}}: log into {{client_name}} -
-

^NON : {{issuer_name}}

-

You are logging into

-

Přihlašujete se do

-

{{name}}

-
- - -
-
- -

- or generate a new account number - -

+
+ +
+

^NON : {{issuer_name}}

+

+ Logging into: + + {{client_name}} +

+ +
+
+ + +
+ +
+ -
-
+

{{notice}}

+ + + diff --git a/src/homepage.html b/src/homepage.html new file mode 100644 index 0000000..41aecfc --- /dev/null +++ b/src/homepage.html @@ -0,0 +1,127 @@ + + + + + + + ^NON at {{issuer_name}} + + + + +
+
+

This is ^NON

+

Toto je ^NON

+

An anonymous identity provider for accessing services from {{issuer_name}}.

+ +
+

No account saved in browser.

+ +

Account saved in browser. Forget?

+ +
+ +
+

How does it work?

+

+ ^NON [anon] creates your identities for different services based on an account code, which you can generate + during login. Each service gets a different identity, so nobody can connect data between them. You, + on the other hand, can connect to all of them using only a single account code. +

+

You can check out the technical details here.

+
+ + +
+
+ + + + diff --git a/src/main.rs b/src/main.rs index 90a95eb..8dd59a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,15 @@ -mod names; +mod accounts; + use async_std::fs; use async_std::fs::File; use async_std::sync::Mutex; use ring::rand::SecureRandom; use ring::rand::SystemRandom; -use serde_json::Value; use std::collections::HashMap; use std::collections::LinkedList; use std::env; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -16,7 +18,7 @@ use tide::log; use tide::prelude::json; use tide::Request; use tide::Response; -use toml::Table; +use tide_serve_dir_macro::auto_serve_dir; use anyhow::{anyhow, Result}; use async_std::io::ReadExt; @@ -27,149 +29,38 @@ use std::fmt; use base64::{engine::general_purpose as base64_coder, Engine as _}; +const TOKEN_EXPIRATION: u64 = 600; +const AUTHORIZATION_EXPIRATION: u64 = 60; + #[derive(Deserialize, Clone)] pub struct Client { - pub name: String, - pub client_secret: String, - pub redirect_uris: Vec, + name: String, + client_secret: String, + redirect_uris: Vec, } #[derive(Deserialize)] pub struct Config { - pub host: String, - pub port: u16, - pub clients: Table, - pub issuer_uri: String, - pub issuer_name: String, - pub rsa_key_file: String, - pub salt: String, + host: String, + port: u16, + clients: HashMap, + issuer_uri: String, + issuer_name: String, + rsa_key_file: String, + salt: String, } impl Config { pub async fn from_file(file: &mut File) -> Result { let mut buf = String::new(); file.read_to_string(&mut buf).await?; - match toml::from_str::(&buf) { + match serde_yaml::from_str::(&buf) { Ok(v) => Ok(v), Err(e) => Err(anyhow!("failed to parse config file: {}", e)), } } } -async fn error_handler(mut res: tide::Response) -> tide::Result { - if let Some(err) = res.downcast_error::() { - res.set_body(tide::Body::from_json(err)?); - res.set_status(400); - } - Ok(res) -} - -async fn authorize(req: Request) -> tide::Result { - #[derive(Deserialize)] - struct Query { - // response_type: String, - client_id: String, - // scope: String, // dont care rn - state: String, - redirect_uri: String, - uid: Option, - code_challenge: Option, - code_challenge_method: Option, - nonce: Option, - } - let q: Query = req.query()?; - - let i = req - .state() - .clients - .get(&q.client_id) - .ok_or(OAuthError::new("invalid_client", "Unknown client"))?; - - // return the login page - if q.uid.is_none() { - return Ok(Response::builder(200) - .body( - // I could use a rendering library here, but its literally as simple as replacing - // two strings. - include_str!("authorization.html") - .replace("{{name}}", &i.name) - .replace("{{issuer_name}}", &req.state().issuer_name), - ) - .header("Content-Type", "text/html") - .into()); - } - - let uid = q.uid.unwrap(); - - // check redirect uri validity - if i.redirect_uris.iter().all(|r| r.as_str() != q.redirect_uri) { - return Err(OAuthError::new("invalid_redirect", "").into()); - } - - let random = SystemRandom::new(); - let mut salt = [0u8; 32]; - random.fill(&mut salt)?; - - let code = base64_coder::URL_SAFE_NO_PAD.encode(&salt); - - let redirect = - Url::parse_with_params(&q.redirect_uri, &[("state", &q.state), ("code", &code)])?; - - let pkce = if q.code_challenge.is_some() && q.code_challenge_method.is_some() { - Some(PKCE { - challenge: q.code_challenge.unwrap(), - method: q.code_challenge_method.unwrap(), - }) - } else { - None - }; - - let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - - let mut auths = req.state().authorizations.lock().await; - - // clean up old authorizations - while let Some(s) = auths.expirations.front() { - if s.1 > timestamp { - break; - } - let s = auths.expirations.pop_front().unwrap(); - auths.auths.remove(&s.0); - } - - auths.auths.insert( - code.clone(), - Authorization { - client_id: q.client_id, - uid, - redirect_uri: q.redirect_uri, - pkce, - nonce: q.nonce, - exp: timestamp + 60, - }, - ); - auths.expirations.push_back((code.clone(), timestamp + 60)); - - let mut resp = Response::new(302); - resp.insert_header("Location", redirect.as_str()); - - Ok(resp) -} - -fn parse_basic_auth(header: &str) -> Option<(String, String)> { - let v = header.strip_prefix("Basic ")?; - - let parsed = String::from_utf8(base64_coder::STANDARD.decode(v).ok()?).ok()?; - - let parts: Vec<&str> = parsed.split(':').take(2).collect(); - - if parts.len() != 2 { - return None; - } - - Some((parts[0].to_owned(), parts[1].to_owned())) -} - #[derive(Debug, Serialize)] pub struct OAuthError { pub error: String, @@ -193,65 +84,208 @@ impl fmt::Display for OAuthError { impl Error for OAuthError {} -fn create_id_token_claims( - salt: &str, - issuer_uri: &str, - client_id: &str, - uid: &str, +async fn error_handler(res: tide::Response) -> tide::Result { + if let Some(err) = res.downcast_error::() { + return Ok(tide::Response::builder(400) + .body(tide::Body::from_json(err)?) + .build()); + } + Ok(res) +} + +#[derive(Deserialize)] +struct AuthorizationQuery { + response_type: String, + client_id: String, + redirect_uri: String, + state: Option, + code_challenge: Option, + code_challenge_method: Option, nonce: Option, -) -> anyhow::Result { - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + // scope: String, // we only issue tokens with the "openid email" scope +} - use ring::hmac; - let mangler = hmac::Key::new(hmac::HMAC_SHA256, (salt.to_owned() + uid).as_bytes()); +fn redirect_with_query(redirect_uri: &str, query: &[(&str, Option<&str>)]) -> tide::Result { + let filtered = query.into_iter().filter_map(|x| x.1.map(|v| (x.0, v))); + let redirect = Url::parse_with_params(redirect_uri, filtered)?; + Ok(tide::Redirect::new(redirect).into()) +} - let mangle = |what: &str| { - let tag = hmac::sign(&mangler, (what.to_owned() + client_id).as_bytes()); - hex::encode(tag.as_ref()) - }; - let choose = |what: &str, from: &[&'static str]| { - let tag = hmac::sign(&mangler, (what.to_owned() + client_id).as_bytes()); - let bytes = tag.as_ref(); - let mut i: usize = 0; - i = (i << 8) + bytes[0] as usize; - i = (i << 8) + bytes[1] as usize; - i = (i << 8) + bytes[2] as usize; - i = (i << 8) + bytes[3] as usize; - from[i % from.len()] - }; +fn render_login_page(client_name: &str, issuer_name: &str, notice: &str) -> tide::Response { + Response::builder(200) + .body( + // I could use a rendering library here, but its literally as simple as replacing + // a few strings from a trusted config. + include_str!("authorization.html") + .replace("{{client_name}}", client_name) + .replace("{{issuer_name}}", issuer_name) + .replace("{{notice}}", notice), + ) + .header("Content-Type", "text/html") + .into() +} +async fn login_page_endpoint(req: Request) -> tide::Result { + let query: AuthorizationQuery = req.query()?; - let adjective = choose("family_name", names::ADJECTIVES); - let animal = choose("given_name", names::ANIMALS); + let client = req + .state() + .config + .clients + .get(&query.client_id) + .ok_or(OAuthError::new("invalid_client", "Unknown client"))?; - // Generate an identity unique to the specified client_id - // This way nobody can take data from different clients and correlate - // user information. Since we don't even keep logs here, there is no - // way to know who is who. - let mut body = json!({ - "kid": "master", - "iss": issuer_uri, - "sub": mangle("sub")[0..32], - "aud": client_id, - "exp": now+600, - "iat": now, - "given_name": animal, - "family_name": adjective, - "email": mangle("email")[0..15].to_owned()+"@email.invalid", - "email_verified": false, - "preferred_username": adjective.to_owned()+animal, - }); - - if let Some(nonce) = nonce { - body["nonce"] = nonce.into(); + // check redirect uri validity + if client + .redirect_uris + .iter() + .all(|r| r.as_str() != query.redirect_uri) + { + return Err(OAuthError::new("invalid_redirect", "").into()); } - Ok(body) + if query.response_type != "code" { + return redirect_with_query( + query.redirect_uri.as_str(), + &[ + ("state", query.state.as_deref()), + ("error", Some("unsupported_response_type")), + ], + ); + } + + Ok(render_login_page( + &client.name, + &req.state().config.issuer_name, + "", + )) +} + +async fn authorize_endpoint(mut req: Request) -> tide::Result { + req.state().total_logins.fetch_add(1, Ordering::Relaxed); + + let query: AuthorizationQuery = req.query()?; + + #[derive(Deserialize)] + struct LoginForm { + account: String, + //csrf_token: String, + } + + let body: LoginForm = req.body_form().await?; + + let client = req + .state() + .config + .clients + .get(&query.client_id) + // only devs should see this error + .ok_or(OAuthError::new("invalid_client", "Unrecognized client"))?; + + if client + .redirect_uris + .iter() + .all(|r| r.as_str() != query.redirect_uri) + { + // only devs should see this error + return Err(OAuthError::new("invalid_redirect", "").into()); + } + + if query.response_type != "code" { + return redirect_with_query( + query.redirect_uri.as_str(), + &[ + ("state", query.state.as_deref()), + ("error", Some("unsupported_response_type")), + ], + ); + } + + let account_code = accounts::normalize(&body.account); + if account_code.is_none() { + let mut login_page = render_login_page( + &client.name, + &req.state().config.issuer_name, + "Account code incorrect", + ); + login_page.set_status(400); + return Ok(login_page); + } + let account_code = account_code.unwrap(); + + let pkce = if query.code_challenge.is_some() && query.code_challenge_method.is_some() { + Some(PKCE { + challenge: query.code_challenge.unwrap(), + method: query.code_challenge_method.unwrap(), + }) + } else { + None + }; + + let mut code = [0u8; 32]; + SystemRandom::new().fill(&mut code)?; + + let code = base64_coder::URL_SAFE_NO_PAD.encode(&code); + + let redirect = redirect_with_query( + query.redirect_uri.as_str(), + &[("state", query.state.as_deref()), ("code", Some(&code))], + )?; + + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + { + let mut auths = req.state().authorizations.lock().await; + + // clean up old authorizations + while let Some(s) = auths.expirations.front() { + if s.1 > timestamp { + break; + } + + let s = auths.expirations.pop_front().unwrap(); + + if auths.auths.remove(&s.0).is_some() { + req.state().expired_logins.fetch_add(1, Ordering::Relaxed); + } + } + + auths.auths.insert( + code.clone(), + Authorization { + client_id: query.client_id, + account: account_code, + redirect_uri: query.redirect_uri, + pkce, + nonce: query.nonce, + exp: timestamp + AUTHORIZATION_EXPIRATION, + }, + ); + auths + .expirations + .push_back((code.clone(), timestamp + AUTHORIZATION_EXPIRATION)); + } + + Ok(redirect) +} + +fn parse_basic_auth(header: &str) -> Option<(String, String)> { + let v = header.strip_prefix("Basic ")?; + + let parsed = String::from_utf8(base64_coder::STANDARD.decode(v).ok()?).ok()?; + + let parts: Vec<&str> = parsed.split(':').take(2).collect(); + + if parts.len() != 2 { + return None; + } + + Some((parts[0].to_owned(), parts[1].to_owned())) } fn create_id_token( app_state: &AppState, client_id: &str, - uid: &str, + normalized_account: &str, nonce: Option, ) -> anyhow::Result { let header = base64_coder::URL_SAFE_NO_PAD.encode( @@ -263,13 +297,13 @@ fn create_id_token( ); let claims = base64_coder::URL_SAFE_NO_PAD.encode( - create_id_token_claims( - &app_state.salt, - &app_state.issuer_uri, + accounts::create_id_token_claims( + &app_state.config.salt, + &app_state.config.issuer_uri, client_id, - uid, - nonce, - )? + normalized_account, + nonce.as_deref(), + ) .to_string(), ); @@ -287,7 +321,7 @@ fn create_id_token( Ok(format!("{}.{}", message, signature)) } -async fn authenticate(mut req: Request) -> tide::Result { +async fn authenticate_endpoint(mut req: Request) -> tide::Result { #[derive(Deserialize)] struct Body { grant_type: String, @@ -313,18 +347,20 @@ async fn authenticate(mut req: Request) -> tide::Result { return None; } Some((body.client_id.unwrap(), body.client_secret.unwrap())) - }); - let credentials = credentials.ok_or(OAuthError::new( - "invalid_client", - "Credentials not found in Basic auth or in req body", - ))?; + }) + .ok_or(OAuthError::new( + "invalid_client", + "Credentials not found in Basic auth or in req body", + ))?; // authenticate client let client_info = req .state() + .config .clients .get(&credentials.0) .ok_or(OAuthError::new("invalid_client", "Unknown client"))?; + if ring::constant_time::verify_slices_are_equal( credentials.1.as_bytes(), &client_info.client_secret.as_bytes(), @@ -335,7 +371,7 @@ async fn authenticate(mut req: Request) -> tide::Result { } // check authorization code - // this actuall consumes the code. It is not possible to retry auth with the same + // this actually consumes the code. It is not possible to retry auth with the same // authorization code. It's always the client's fault, so we don't care really. let code_info = { let mut auths = req.state().authorizations.lock().await; @@ -354,8 +390,7 @@ async fn authenticate(mut req: Request) -> tide::Result { } // verify PKCE if the client used it - if code_info.pkce.is_some() { - let pkce = code_info.pkce.unwrap(); + if let Some(pkce) = code_info.pkce { let code_verifier = body .code_verifier .ok_or(OAuthError::new("invalid_request", "PKCE not present"))?; @@ -364,12 +399,11 @@ async fn authenticate(mut req: Request) -> tide::Result { "S256" => { let sha_digest = ring::digest::digest(&ring::digest::SHA256, code_verifier.as_bytes()); - let a = base64_coder::URL_SAFE_NO_PAD.encode(sha_digest.as_ref()); - a + Ok(base64_coder::URL_SAFE_NO_PAD.encode(sha_digest.as_ref())) } - _ => pkce.challenge.clone(), // not the best, but since the only other option is - // "plain", then I guess its fine - }; + "plain" => Ok(pkce.challenge.clone()), + _ => Err(OAuthError::new("invalid_request", "invalid PKCE method")), + }?; // Needs constant time equality checking since we might be comparing plain text. // This is a non-issue when using the S265 method @@ -383,16 +417,26 @@ async fn authenticate(mut req: Request) -> tide::Result { } } + // The token is random because there are no resources protected by the token anyways. + let mut access_token = [0u8; 32]; + SystemRandom::new().fill(&mut access_token)?; + let access_token = base64_coder::URL_SAFE_NO_PAD.encode(&access_token); + + req.state() + .successful_logins + .fetch_add(1, Ordering::Relaxed); + // give access code Ok(Response::builder(200).body(json!({ - "access_token": "SOME-TOKEN", + "access_token": access_token, "token_type": "Bearer", - "expires_in": 600, - "id_token": create_id_token(req.state(), &credentials.0, &code_info.uid, code_info.nonce)?, + "expires_in": TOKEN_EXPIRATION, + "id_token": create_id_token(req.state(), &credentials.0, &code_info.account, code_info.nonce)?, + "scope": "openid profile email" })).into()) } -async fn jwks(req: Request) -> tide::Result { +async fn jwks_endpoint(req: Request) -> tide::Result { let pk: ring::rsa::PublicKeyComponents> = req.state().signing_key.public().into(); Ok(Response::builder(200) .body(json!({ @@ -408,35 +452,77 @@ async fn jwks(req: Request) -> tide::Result { } async fn configuration_endpoint(req: Request) -> tide::Result { - let uri = Url::parse(&req.state().issuer_uri)?; + let uri = Url::parse(&req.state().config.issuer_uri)?; Ok(Response::builder(200) .body(json!({ "issuer": uri, "authorization_endpoint": uri.join("/authorize")?, "token_endpoint": uri.join("/token")?, "jwks_uri": uri.join("/jwks")?, - "response_types_supported": ["code", "id_token"], + "response_types_supported": ["code"], "subject_types_supported": ["pairwise"], "id_token_signing_alg_values_supported": ["RS256"], })) .into()) } +async fn homepage_endpoint(req: Request) -> tide::Result { + Ok(Response::builder(200) + .body( + include_str!("homepage.html") + .replace("{{issuer_name}}", &req.state().config.issuer_name), + ) + .header("Content-Type", "text/html") + .into()) +} + +/// fallback endpoint for non-JS users +async fn create_account_endpoint(_req: Request) -> tide::Result { + Ok(Response::builder(200) + .body(format!( + "Your account number is: {}.\nKeep it safe and out of sight!", + accounts::generate_account()? + )) + .header("Content-Type", "text/plain") + .into()) +} + +async fn metrics_endpoint(req: Request) -> tide::Result { + Ok(Response::builder(200) + .body(format!( + r#" +# TYPE logins_total counter +logins_total {} +# TYPE logins_expired counter +logins_expired {} +# TYPE logins_success counter +logins_success {} +"#, + req.state().total_logins.load(Ordering::Relaxed), + req.state().expired_logins.load(Ordering::Relaxed), + req.state().successful_logins.load(Ordering::Relaxed), + )) + .header("Content-Type", "text/plain; version=0.0.4") + .into()) +} + pub struct AuthStore { pub auths: HashMap, pub expirations: LinkedList<(String, u64)>, } -#[derive(Clone)] -pub struct AppState { - pub clients: Arc>, - pub authorizations: Arc>, - pub issuer_uri: String, - pub issuer_name: String, - pub signing_key: Arc, - pub salt: String, +pub struct AppStateRaw { + pub config: Config, + pub authorizations: Mutex, + pub signing_key: ring::rsa::KeyPair, + pub total_logins: AtomicUsize, + pub successful_logins: AtomicUsize, + pub expired_logins: AtomicUsize, + pub server_errors: AtomicUsize, } +type AppState = Arc; + pub struct PKCE { pub challenge: String, pub method: String, @@ -444,7 +530,7 @@ pub struct PKCE { pub struct Authorization { pub client_id: String, - pub uid: String, + pub account: String, pub redirect_uri: String, pub pkce: Option, pub nonce: Option, @@ -453,43 +539,47 @@ pub struct Authorization { #[async_std::main] async fn main() -> anyhow::Result<()> { - // log::start(); + log::with_level(log::LevelFilter::Debug); let mut conf_file = - File::open(env::var("CONFIG_FILE").unwrap_or("config.toml".to_owned())).await?; + File::open(env::var("CONFIG_FILE").unwrap_or("config.yml".to_owned())).await?; let config = Config::from_file(&mut conf_file).await?; - let mut clients: HashMap = HashMap::new(); - for c in config.clients { - clients.insert(c.0, c.1.try_into()?); - } + let bind_address = format!("{}:{}", &config.host, &config.port); - let key_data = fs::read(&config.rsa_key_file).await?; + let signing_key = ring::rsa::KeyPair::from_pkcs8(&fs::read(&config.rsa_key_file).await?)?; - let mut app = tide::with_state(AppState { - clients: Arc::new(clients), - authorizations: Arc::new(Mutex::new(AuthStore { + let mut app = tide::with_state(Arc::new(AppStateRaw { + config, + authorizations: Mutex::new(AuthStore { auths: HashMap::new(), expirations: LinkedList::new(), - })), - issuer_uri: config.issuer_uri, - issuer_name: config.issuer_name, - salt: config.salt, - signing_key: Arc::new( - ring::rsa::KeyPair::from_pkcs8(&key_data)?, - ), - }); + }), + signing_key, + total_logins: AtomicUsize::new(0), + successful_logins: AtomicUsize::new(0), + expired_logins: AtomicUsize::new(0), + server_errors: AtomicUsize::new(0), + })); app.with(tide::utils::After(error_handler)); - app.at("/authorize").get(authorize); - app.at("/token").post(authenticate); - app.at("/jwks").get(jwks); + app.at("/").get(homepage_endpoint); + app.at("/authorize") + .get(login_page_endpoint) + .post(authorize_endpoint); + app.at("/token").post(authenticate_endpoint); + app.at("/jwks").get(jwks_endpoint); app.at("/.well-known/openid-configuration") .get(configuration_endpoint); + app.at("/new-account").get(create_account_endpoint); + app.at("/metrics").get(metrics_endpoint); - app.listen(format!("{}:{}", config.host, config.port)) - .await?; + auto_serve_dir!(app, "/static", "static"); + + println!("Server started at {}", &bind_address); + + app.listen(bind_address).await?; Ok(()) } diff --git a/src/names.rs b/src/names.rs deleted file mode 100644 index 66c9dc9..0000000 --- a/src/names.rs +++ /dev/null @@ -1,24 +0,0 @@ -pub const ADJECTIVES: &[&'static str] = &[ - "Affinity", "Artisan", "Benevolent", "Breezy", "Cheerful", "Compass", "Curious", - "Eager", "Ebullient", "Effervescent", "Esteem", "Evergreen", "Flourish", "Genuine", - "Grounded", "Harmony", "Inventive", "Journey", "Jubilant", "Kaleidoscope", "Kaleidoscopic", - "Kindred", "Marvel", "Melodious", "Optimistic", "Resilient", "Riverstone", "Serendipitous", - "Serendipity", "Serene", "Sincere", "Skybound", "Sparkling", "Sprightly", - "Stardust", "Stargazer", "Starry", "Steadfast", "Sunbeam", "Tapestry", - "Tenacious", "Upbeat", "Valiant", "Verdant", "Vigorous", "Wanderlust", - "Whimsical", "Witty", "Wonderstruck", "Zephyr" -]; - -pub const ANIMALS: &[&'static str] = &[ - "Axolotl", "Badger", "Baku", "Butterfly", "Capybara", "Carbuncle", - "Chameleon", "Cheetah", "Deer", "Dolphin", "Dryad", "Echidna", - "Elephant", "Fennec Fox", "Flamingo", "Fox", "Fu", "Gnome", - "Hippocampus", "Hippogriff", "Hippopotamus", "Hummingbird", "Iguana", "Kangaroo", - "Kelpie", "Kirin", "Koala", "Dragon", "Leviathan", "Lion", - "Lynx", "Manatee", "Manok", "Meerkat", "Mooncalf", "Naiad", - "Narwhal", "Orangutan", "Owl", "Panda", "Parrot", "Peacock", - "Penguin", "Phoenix", "Pixie", "Qilin", "Quokka", "Raccoon", - "Otter", "Seahorse", "Serpent", "Simurgh", "Sphynx", "Sprite", - "Squirrel", "Sylph", "Tapir", "Toucan", "Turtle", "Unicorn", - "Whale", "Ziz" -]; diff --git a/static/fonts.css b/static/fonts.css new file mode 100644 index 0000000..5385548 --- /dev/null +++ b/static/fonts.css @@ -0,0 +1,39 @@ +/* Generated using https://gwfh.mranftl.com/fonts/poppins?subsets=latin,latin-ext */ +/* Uses the Poppins font from Google Fonts */ + +/* poppins-regular - latin_latin-ext */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Poppins'; + font-style: normal; + font-weight: 400; + src: url('fonts/poppins-v21-latin_latin-ext-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* poppins-italic - latin_latin-ext */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Poppins'; + font-style: italic; + font-weight: 400; + src: url('fonts/poppins-v21-latin_latin-ext-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* poppins-700 - latin_latin-ext */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Poppins'; + font-style: normal; + font-weight: 700; + src: url('fonts/poppins-v21-latin_latin-ext-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* poppins-700italic - latin_latin-ext */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Poppins'; + font-style: italic; + font-weight: 700; + src: url('fonts/poppins-v21-latin_latin-ext-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + diff --git a/static/fonts/poppins-v21-latin_latin-ext-700.woff2 b/static/fonts/poppins-v21-latin_latin-ext-700.woff2 new file mode 100644 index 0000000..6b4c85d Binary files /dev/null and b/static/fonts/poppins-v21-latin_latin-ext-700.woff2 differ diff --git a/static/fonts/poppins-v21-latin_latin-ext-700italic.woff2 b/static/fonts/poppins-v21-latin_latin-ext-700italic.woff2 new file mode 100644 index 0000000..573984f Binary files /dev/null and b/static/fonts/poppins-v21-latin_latin-ext-700italic.woff2 differ diff --git a/static/fonts/poppins-v21-latin_latin-ext-italic.woff2 b/static/fonts/poppins-v21-latin_latin-ext-italic.woff2 new file mode 100644 index 0000000..50f42b9 Binary files /dev/null and b/static/fonts/poppins-v21-latin_latin-ext-italic.woff2 differ diff --git a/static/fonts/poppins-v21-latin_latin-ext-regular.woff2 b/static/fonts/poppins-v21-latin_latin-ext-regular.woff2 new file mode 100644 index 0000000..115a513 Binary files /dev/null and b/static/fonts/poppins-v21-latin_latin-ext-regular.woff2 differ