refactor, rethink, rewrite

This commit is contained in:
bain 2024-06-01 22:44:32 +02:00
parent dde44c88cc
commit b4db5a8667
Signed by: bain
GPG key ID: 31F0F25E3BED0B9B
13 changed files with 939 additions and 403 deletions

114
Cargo.lock generated
View file

@ -82,8 +82,9 @@ dependencies = [
"ring", "ring",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"tide", "tide",
"toml", "tide-serve-dir-macro",
] ]
[[package]] [[package]]
@ -1382,6 +1383,15 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" 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]] [[package]]
name = "semver" name = "semver"
version = "0.9.0" version = "0.9.0"
@ -1448,15 +1458,6 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@ -1469,6 +1470,19 @@ dependencies = [
"serde", "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]] [[package]]
name = "sha1" name = "sha1"
version = "0.6.1" version = "0.6.1"
@ -1738,6 +1752,18 @@ dependencies = [
"serde_json", "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]] [[package]]
name = "time" name = "time"
version = "0.2.27" version = "0.2.27"
@ -1791,40 +1817,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 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]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.40" version = "0.1.40"
@ -1878,6 +1870,12 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@ -1944,6 +1942,16 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c4517f54858c779bbcbf228f4fca63d121bf85fbecb2dc578cdf4a39395690" 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]] [[package]]
name = "wasi" name = "wasi"
version = "0.9.0+wasi-snapshot-preview1" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 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]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"
@ -2196,12 +2213,3 @@ name = "windows_x86_64_msvc"
version = "0.52.3" version = "0.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0770833d60a970638e989b3fa9fd2bb1aaadcf88963d1659fd7d9990196ed2d6" checksum = "0770833d60a970638e989b3fa9fd2bb1aaadcf88963d1659fd7d9990196ed2d6"
[[package]]
name = "winnow"
version = "0.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
dependencies = [
"memchr",
]

View file

@ -10,8 +10,9 @@ async-std = { version = "1.8.0", features = ["attributes"] }
tide = { version = "0.16.0", features = [] } tide = { version = "0.16.0", features = [] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
serde_yaml = "0.9.34"
ring = { version = "0.17.8", features = ["std"] } ring = { version = "0.17.8", features = ["std"] }
anyhow = "1.0.70" anyhow = "1.0.70"
base64 = "0.21.0" base64 = "0.21.0"
toml = "0.7.3"
hex = "0.4.3" hex = "0.4.3"
tide-serve-dir-macro = "0.1"

View file

@ -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. their identities are derived on-demand.
Each identity is separate for different services, but can be accessed from a 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. can correlate user information across services.
## Installation ## Installation
@ -14,4 +14,21 @@ can correlate user information across services.
2. fill out `config.toml.sample`. The server expects a file called 2. fill out `config.toml.sample`. The server expects a file called
`config.toml` in its working directory. `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 :)

165
src/accounts.rs Normal file
View file

@ -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<String> {
// 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<u8> = 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<String> {
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
}

View file

@ -4,10 +4,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1.0, width=device-width" /> <meta name="viewport" content="initial-scale=1.0, width=device-width" />
<title></title> <title>{{issuer_name}}: log into {{client_name}}</title>
<link href="/static/style.css" rel="stylesheet" /> <link href="/static/style.css" rel="stylesheet" />
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); @import url('/static/fonts.css');
:root { :root {
--background: rgb(24, 24, 27); --background: rgb(24, 24, 27);
@ -21,7 +21,6 @@
.issuer-name { .issuer-name {
color: var(--primary); color: var(--primary);
font-weight: bolder;
} }
.issuer-name a { .issuer-name a {
@ -40,9 +39,9 @@
width: 19ch; width: 19ch;
border: none; border: none;
padding: 0.4rem 1rem; padding: 0.4rem 1rem;
padding-right: 0;
background: none; background: none;
color: inherit; color: inherit;
text-transform: uppercase;
} }
@media only screen and (max-width: 40em) { @media only screen and (max-width: 40em) {
@ -56,10 +55,13 @@
} }
html { html {
height: 100vmin; height: 100vh;
} }
body { body {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--background); background: var(--background);
color: var(--text); color: var(--text);
font-family: Poppins, sans-serif; font-family: Poppins, sans-serif;
@ -67,9 +69,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
main { .center {
margin: 20vmin auto 0; margin: 20vmin auto 0;
width: min-content; width: min-content;
flex-grow: 1;
} }
.client-name { .client-name {
@ -98,26 +101,23 @@
} }
.container button { .container button {
display: inline-flex; margin: 6px 1rem 0 0;
align-items: center;
margin: 3px 1rem 0;
} }
button { button, input[type=submit] {
background: none; background: none;
border: none; border: none;
color: var(--text); color: var(--text);
cursor: pointer; cursor: pointer;
} }
.login-button { form .login-button {
background: var(--secondary); background: var(--secondary);
padding: 0.8rem; padding: 0.5rem 0.8rem;
font-weight: bold; font-weight: bold;
font-size: larger; font-size: larger;
width: 100%;
margin: 0.5rem 0 0;
border-radius: 5px; border-radius: 5px;
margin-left: auto;
} }
.login-button:hover { .login-button:hover {
@ -133,6 +133,10 @@
color: inherit; color: inherit;
} }
.hidden {
display: none;
}
.info-accounts { .info-accounts {
background: var(--caution-darker); background: var(--caution-darker);
width: 100%; width: 100%;
@ -148,11 +152,12 @@
opacity: 0; opacity: 0;
} }
button[data-closed="false"] .closed {
button[data-state="password"] .closed {
display: none; display: none;
} }
button[data-closed="true"] .open { button[data-state="visible"] .open {
display: none; display: none;
} }
@ -170,19 +175,55 @@
color: #fefefe; color: #fefefe;
} }
} }
.login-action-container {
margin: 0.5rem 0 0;
display: flex;
width: 100%;
}
.login-action-container > a {
text-decoration: none;
font-size: medium;
font-weight: bold;
align-content: center;
:hover {
text-decoration: underline;
}
}
.notice:empty {
display: none;
}
noscript {
margin-top: 3rem;
}
footer {
width: max-content;
margin: 1.5em auto;
}
</style> </style>
</head> </head>
<body> <body>
<div class="center">
<noscript>Enable JS to save the account in your browser</noscript>
<main> <main>
<h1 class="issuer-name"><a href="/">^NON</a> : {{issuer_name}}</h1> <h1 class="issuer-name"><a href="/" target="_blank">^NON</a> : {{issuer_name}}</h1>
<h1 lang="en">You are logging into</h1> <h1>
<h1 lang="cs" hidden>Přihlašujete se do</h1> <span lang="en">Logging into:</span>
<p class="client-name">{{name}}</p> <span lang="cs" hidden>Přihlášení:</span>
<div class="container" id="accICont"> <span style="font-weight:normal"> {{client_name}}</span>
<input id="accInput" inputmode="numeric" autocomplete="current-password" autofocus type="password" </h1>
class="account-number-input" placeholder="0000000000000000" maxlength="16" />
<button id="showHideButton" data-closed="false" type="button" onclick="toggleInputType()"> <form id="loginForm" name="login" method="post" enctype="application/x-www-form-urlencoded" >
<div class="container" id="accInputCont">
<input id="accInput" name="account" inputmode="numeric" autocomplete="current-password" autofocus type="password"
class="account-number-input" placeholder="0000000000000000" maxlength="16" required />
<button hidden id="showCodeButton" data-state="password" type="button" onclick="toggleInputType()">
<svg class="open" xmlns="http://www.w3.org/2000/svg" height="24" fill="currentColor" <svg class="open" xmlns="http://www.w3.org/2000/svg" height="24" fill="currentColor"
viewBox="0 -960 960 960" width="24"> viewBox="0 -960 960 960" width="24">
<path <path
@ -194,56 +235,87 @@
</svg> </svg>
</button> </button>
</div> </div>
<div> <div class="login-action-container">
<button class="login-button" onclick="login()" type="button"> <a id="newAccountButton" href="/new-account" target="_blank">
<span lang="en">Log in</span><span lang="cs" hidden>Přihlásit se</span></button> <span class="generate" lang="en">Generate new account</span>
<p class="generate-text"><a href="javascript:void(0)" onclick="generateAccount()"> <span class="generate" lang="cs" hidden>Generovat nový účet</span>
<span lang="en">or generate a new account number</span> <span class="login-once hidden" lang="en">Log in once</span>
<span lang="cs" hidden>nebo vytvořit nové číslo</span> <span class="login-once hidden" lang="cs" hidden>Přihlásit se jednou</span>
</a></p> </a>
<p id="infoBox" hidden class="info-accounts hidden"> <button id="loginButton" class="login-button">
<span lang="en"><b>Careful!</b> Your account number is the only way to access your accounts. Keep it <!-- this will get changed to "Log in" by JS -->
somewhere safe and out of sight!</span> <span id="loginButtonText" lang="en">Log in once</span>
<span lang="cs" hidden><b>Pozor!</b> Toto číslo je jediný způsob, jak se přihlásit k vašim účtům. <span lang="cs" hidden>Přihlásit se</span>
Poznamenejte si ho a s nikým ho nesdílejte.</span> </button>
</p>
</div> </div>
</form>
<p id="infoBox" hidden class="info-accounts hidden">
<span lang="en">
<b>Careful!</b> This code is the only way to access your accounts. Keep it
somewhere safe and out of sight!
</span>
<span lang="cs" hidden>
<b>Pozor!</b> Tento kód je jediný způsob, jak se přihlásit k vašim účtům.
Poznamenejte si ho a s nikým ho nesdílejte.
</span>
</p>
<p class="info-accounts notice">{{notice}}</p>
</main> </main>
</div>
<footer>
<a href="/" target="_blank">What is this?</a>
<span id="forgetAccountButton" hidden> · <a href="javascript:forget()">Forget account</a></span>
</footer>
<script> <script>
accInput.addEventListener("beforeinput", ev => {
if (!/[0-9]*/.test(ev.data)) { accInput.addEventListener("input", ev => {
const p = [...accInput.value.replace(/ +/g, "")];
if (p.length == 16) {
const payload = p.slice(0, p.length - 1);
accInputCont.dataset.valid = p[0].toLowerCase() == 'a' && calculateChecksum(payload) == p[p.length - 1].toLowerCase();
} else {
accInputCont.dataset.valid = "pending";
}
updateSecondaryAction();
if (accInput.type != "password") formatInput();
});
loginForm.addEventListener("submit", ev => {
if (accInputCont.dataset.valid != "true") {
ev.preventDefault(); ev.preventDefault();
return; return;
} }
});
accInput.addEventListener("input", ev => { accInput.value = accInput.value.replace(/ +/g, "");
if (accInput.value.length == accInput.maxLength) {
const p = [...accInput.value.replace(/ +/g, "")]; if (ev.submitter === loginButton) {
const payload = p.slice(0, p.length - 1); window.localStorage.setItem("code", accInput.value);
accICont.dataset.valid = calculateLuhnPrime(payload) == p[p.length - 1];
} else {
accICont.dataset.valid = "pending";
} }
if (accInput.type == "password") return;
formatInput();
}); });
function formatInput() { function formatInput() {
const caret = accInput.selectionStart; const caret = accInput.selectionStart;
const formatted = [...accInput.value.replace(/ +/g, "")].map((c, i) => i != 0 && i % 4 == 0 ? " " + c : c).join("");
if (formatted != accInput.value) {
const digitsBeforeCaret = accInput.value.slice(0, caret).replace(/ +/g, "").length; const digitsBeforeCaret = accInput.value.slice(0, caret).replace(/ +/g, "").length;
const offset = Math.floor(digitsBeforeCaret / 4); const offset = Math.floor(digitsBeforeCaret / 4);
// const offset = Math.floor(caret/4) - (caret % 4 == 0);
const formatted = [...accInput.value.replace(/ +/g, "")].map((c, i) => i != 0 && i % 4 == 0 ? " " + c : c).join("");
if (formatted != accInput.value) {
accInput.value = formatted; accInput.value = formatted;
accInput.selectionStart = digitsBeforeCaret + offset; accInput.selectionStart = digitsBeforeCaret + offset;
accInput.selectionEnd = digitsBeforeCaret + offset; accInput.selectionEnd = digitsBeforeCaret + offset;
} }
} }
function toggleInputType() { function toggleInputType() {
showHideButton.dataset.closed = showHideButton.dataset.closed == "true" ? "false" : "true" showCodeButton.dataset.state = showCodeButton.dataset.state == "visible" ? "password" : "visible";
if (accInput.type == "password") { if (showCodeButton.dataset.state == "visible") {
accInput.maxLength = 19; accInput.maxLength = 19;
accInput.placeholder = "0000 0000 0000 0000"; accInput.placeholder = "0000 0000 0000 0000";
formatInput(); formatInput();
@ -256,50 +328,91 @@
} }
} }
function login() { function updateSecondaryAction() {
if (accICont.dataset.valid != "true") return; newAccountButton.target = "_self";
const digits = accInput.value.replace(/ +/g, "");
window.localStorage.setItem("code", digits); if (accInputCont.dataset.valid == 'true') {
let params = new URLSearchParams(window.location.search); document.querySelectorAll('.generate').forEach(el => el.classList.add('hidden'))
params.set("uid", digits); document.querySelectorAll('.login-once').forEach(el => el.classList.remove('hidden'))
window.location.search = params; newAccountButton.href = 'javascript:loginForm.submit()';
} else {
document.querySelectorAll('.generate').forEach(el => el.classList.remove('hidden'))
document.querySelectorAll('.login-once').forEach(el => el.classList.add('hidden'))
newAccountButton.href = 'javascript:generateAccountHandler()';
}
}
function parseDigit(d) {
const n = d.toLowerCase().charCodeAt(0);
return n - 48 - (n > 96) * 39;
}
function makeDigit(d) {
// ascii code of the number (offset 48 for 0-9, offset 97 for a-z)
return String.fromCharCode(39 * (d > 9) + 48 + d);
} }
/** /**
* Luhn algorithm with some changes so people don't put in their credit cards :) * checksum inspired by Luhn's algorithm
*/ */
function calculateLuhnPrime(payload) { function calculateChecksum(payload) {
const sum = payload.map(Number).reduce((a, b, i) => a + (i % 2 == 0 ? [...String(b * 2)].map(Number).reduce((i, j) => i + j) : b), 0); const sum = payload.map(parseDigit).reduce((a, b, i) => a + (i % 2 == 0 ? 2*b : b), 0);
return sum % 10; return makeDigit(sum % 36);
} }
function generateAccount() { function generateAccount() {
accInput.type != "text" && toggleInputType(); let code = "a";
let array = new Uint8Array(15); // Unfortunately here we have to repeatedly sample random values
// until they're smaller than the biggest multiple of 36 in a byte.
// Otherwise we would have an uneven distribution.
let array = new Uint8Array(14);
outer: while (true) {
window.crypto.getRandomValues(array); window.crypto.getRandomValues(array);
let accNumber = [...array].map(n => String(Math.floor(n / 255 * 10))).join("");
accNumber += calculateLuhnPrime([...accNumber]); for (let i = 0; i < array.length; i++) {
if (array[i] >= 255 - 255 % 36) continue;
code += makeDigit(array[i] % 36);
if (code.length === 15) break outer;
}
}
return code + calculateChecksum([...code]);
}
function generateAccountHandler() {
accInput.value = generateAccount();
accInputCont.dataset.valid = true;
accInput.value = accNumber;
accICont.dataset.valid = true;
formatInput(); formatInput();
updateSecondaryAction();
infoBox.hidden = false; infoBox.hidden = false;
setTimeout(() => infoBox.classList.toggle("hidden", false), 0); setTimeout(() => infoBox.classList.toggle("hidden", false), 0);
} }
const savedCode = window.localStorage.getItem("code"); function forget() {
if (savedCode) { window.localStorage.removeItem("code");
accInput.value = savedCode; window.location.reload();
accICont.dataset.valid = true;
} }
// show JS-only features
showCodeButton.removeAttribute('hidden');
loginButtonText.innerText = "Log in";
// i18n :) // i18n :)
if (document.body.querySelectorAll(`[lang="${navigator.language.slice(0, 2)}"]`).length) if (document.body.querySelectorAll(`[lang="${navigator.language.slice(0, 2)}"]`).length)
document.body.querySelectorAll('[lang]').forEach(el => navigator.language.startsWith(el.lang) ? el.removeAttribute('hidden') : el.remove()); document.body.querySelectorAll('[lang]').forEach(el => navigator.language.startsWith(el.lang) ? el.removeAttribute('hidden') : el.remove());
const savedCode = window.localStorage.getItem("code");
if (savedCode) {
accInput.value = savedCode;
accInputCont.dataset.valid = true;
forgetAccountButton.hidden = false;
}
updateSecondaryAction();
</script> </script>
</body> </body>

127
src/homepage.html Normal file
View file

@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
<title>^NON at {{issuer_name}}</title>
<style>
@import url('/static/fonts.css');
:root {
--background: rgb(24, 24, 27);
--text: #fefefe;
--primary: #606060;
--secondary: #303035;
--success: #76B041;
--caution: #9E2A2B;
--caution-darker: #c83033;
}
.header {
color: var(--primary);
font-weight: bolder;
font-size: 32pt;
}
.header span {
color: var(--text);
}
body {
background: var(--background);
color: var(--text);
font-family: Poppins, sans-serif;
margin: 0;
box-sizing: border-box;
font-size: 16pt;
}
.center {
margin: 20vmin 5em 0;
width: min(95vw, 40em);
}
a {
color: var(--text);
font-weight: bold;
}
div[data-acc="no"] > .acc {
display: none;
}
div[data-acc="yes"] > .no-acc {
display: none;
}
@media only screen and (max-width: 60em) {
.center {
margin: 1em;
}
}
@media (prefers-color-scheme: light) {
:root {
--background: #fefefe;
--text: #2f2f2f;
--primary: #b0b0b0;
--secondary: #d9d9d9;
--success: #76B041;
--caution: #FF6B6C;
--caution-darker: #c83033;
}
}
</style>
</head>
<body>
<div class="center">
<main>
<h1 lang="en" class="header">This is <span>^NON</span></h1>
<h1 lang="cs" hidden class="header">Toto je <span>^NON</span></h1>
<p lang="en">An anonymous identity provider for accessing services from <span>{{issuer_name}}</span>.</p>
<p lang="cs" hidden>Zprostředkovatel anonymní identity pro služby od <span>{{issuer_name}}</span>.</p>
<div id="savedAccount" data-acc="no">
<p class="no-acc" lang="en">No account saved in browser.</p>
<p class="no-acc" lang="cs" hidden>Žádný uložený účet.</p>
<p class="acc" lang="en">Account saved in browser. <a href="javascript:forget()">Forget?</a></p>
<p class="acc" lang="cs" hidden>V prohlížeči je uložený účet. <a href="javascript:forget()">Zapomenout?</a></p>
</div>
<div lang="en">
<h2>How does it work?</h2>
<p>
^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.
</p>
<p>You can check out the technical details <a href="https://git.nolog.cz/nolog.cz/anon">here</a>.</p>
</div>
<div lang="cs" hidden>
<h2>Jak to funguje?</h2>
<p>
^NON [anon] vytváří vaše identity pro různé služby pomocí vašeho přihlašovacího kódu, který si můžete vygenerovat
při přihlášení. Ke každé službě máte odlišnou identitu, takže mezi nimi není možné spojit vaše data. Vy se
na druhou stranu můžete ke všem službám přihlásit použitím pouze jednoho přihlašovacího kódu.
</p>
<p>Technické detaily si můžete prozkoumat <a href="https://git.nolog.cz/nolog.cz/anon">zde</a>.</p>
</div>
</main>
</div>
<script>
function forget() {
window.localStorage.removeItem("code");
window.location.reload();
}
if (window.localStorage.getItem("code") != null) savedAccount.dataset.acc = "yes";
// i18n :)
if (document.body.querySelectorAll(`[lang="${navigator.language.slice(0, 2)}"]`).length)
document.body.querySelectorAll('[lang]').forEach(el => navigator.language.startsWith(el.lang) ? el.removeAttribute('hidden') : el.remove());
</script>
</body>
</html>

View file

@ -1,13 +1,15 @@
mod names; mod accounts;
use async_std::fs; use async_std::fs;
use async_std::fs::File; use async_std::fs::File;
use async_std::sync::Mutex; use async_std::sync::Mutex;
use ring::rand::SecureRandom; use ring::rand::SecureRandom;
use ring::rand::SystemRandom; use ring::rand::SystemRandom;
use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::LinkedList; use std::collections::LinkedList;
use std::env; use std::env;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::sync::Arc; use std::sync::Arc;
use std::time::SystemTime; use std::time::SystemTime;
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
@ -16,7 +18,7 @@ use tide::log;
use tide::prelude::json; use tide::prelude::json;
use tide::Request; use tide::Request;
use tide::Response; use tide::Response;
use toml::Table; use tide_serve_dir_macro::auto_serve_dir;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use async_std::io::ReadExt; use async_std::io::ReadExt;
@ -27,149 +29,38 @@ use std::fmt;
use base64::{engine::general_purpose as base64_coder, Engine as _}; use base64::{engine::general_purpose as base64_coder, Engine as _};
const TOKEN_EXPIRATION: u64 = 600;
const AUTHORIZATION_EXPIRATION: u64 = 60;
#[derive(Deserialize, Clone)] #[derive(Deserialize, Clone)]
pub struct Client { pub struct Client {
pub name: String, name: String,
pub client_secret: String, client_secret: String,
pub redirect_uris: Vec<String>, redirect_uris: Vec<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct Config { pub struct Config {
pub host: String, host: String,
pub port: u16, port: u16,
pub clients: Table, clients: HashMap<String, Client>,
pub issuer_uri: String, issuer_uri: String,
pub issuer_name: String, issuer_name: String,
pub rsa_key_file: String, rsa_key_file: String,
pub salt: String, salt: String,
} }
impl Config { impl Config {
pub async fn from_file(file: &mut File) -> Result<Config> { pub async fn from_file(file: &mut File) -> Result<Config> {
let mut buf = String::new(); let mut buf = String::new();
file.read_to_string(&mut buf).await?; file.read_to_string(&mut buf).await?;
match toml::from_str::<Config>(&buf) { match serde_yaml::from_str::<Config>(&buf) {
Ok(v) => Ok(v), Ok(v) => Ok(v),
Err(e) => Err(anyhow!("failed to parse config file: {}", e)), 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::<OAuthError>() {
res.set_body(tide::Body::from_json(err)?);
res.set_status(400);
}
Ok(res)
}
async fn authorize(req: Request<AppState>) -> tide::Result {
#[derive(Deserialize)]
struct Query {
// response_type: String,
client_id: String,
// scope: String, // dont care rn
state: String,
redirect_uri: String,
uid: Option<String>,
code_challenge: Option<String>,
code_challenge_method: Option<String>,
nonce: Option<String>,
}
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)] #[derive(Debug, Serialize)]
pub struct OAuthError { pub struct OAuthError {
pub error: String, pub error: String,
@ -193,65 +84,208 @@ impl fmt::Display for OAuthError {
impl Error for OAuthError {} impl Error for OAuthError {}
fn create_id_token_claims( async fn error_handler(res: tide::Response) -> tide::Result {
salt: &str, if let Some(err) = res.downcast_error::<OAuthError>() {
issuer_uri: &str, return Ok(tide::Response::builder(400)
client_id: &str, .body(tide::Body::from_json(err)?)
uid: &str, .build());
nonce: Option<String>, }
) -> anyhow::Result<Value> { Ok(res)
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
use ring::hmac;
let mangler = hmac::Key::new(hmac::HMAC_SHA256, (salt.to_owned() + uid).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 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()]
};
let adjective = choose("family_name", names::ADJECTIVES);
let animal = choose("given_name", names::ANIMALS);
// 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();
} }
Ok(body) #[derive(Deserialize)]
struct AuthorizationQuery {
response_type: String,
client_id: String,
redirect_uri: String,
state: Option<String>,
code_challenge: Option<String>,
code_challenge_method: Option<String>,
nonce: Option<String>,
// scope: String, // we only issue tokens with the "openid email" scope
}
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())
}
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<AppState>) -> tide::Result {
let query: AuthorizationQuery = req.query()?;
let client = req
.state()
.config
.clients
.get(&query.client_id)
.ok_or(OAuthError::new("invalid_client", "Unknown client"))?;
// check redirect uri validity
if client
.redirect_uris
.iter()
.all(|r| r.as_str() != query.redirect_uri)
{
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")),
],
);
}
Ok(render_login_page(
&client.name,
&req.state().config.issuer_name,
"",
))
}
async fn authorize_endpoint(mut req: Request<AppState>) -> 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( fn create_id_token(
app_state: &AppState, app_state: &AppState,
client_id: &str, client_id: &str,
uid: &str, normalized_account: &str,
nonce: Option<String>, nonce: Option<String>,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
let header = base64_coder::URL_SAFE_NO_PAD.encode( 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( let claims = base64_coder::URL_SAFE_NO_PAD.encode(
create_id_token_claims( accounts::create_id_token_claims(
&app_state.salt, &app_state.config.salt,
&app_state.issuer_uri, &app_state.config.issuer_uri,
client_id, client_id,
uid, normalized_account,
nonce, nonce.as_deref(),
)? )
.to_string(), .to_string(),
); );
@ -287,7 +321,7 @@ fn create_id_token(
Ok(format!("{}.{}", message, signature)) Ok(format!("{}.{}", message, signature))
} }
async fn authenticate(mut req: Request<AppState>) -> tide::Result { async fn authenticate_endpoint(mut req: Request<AppState>) -> tide::Result {
#[derive(Deserialize)] #[derive(Deserialize)]
struct Body { struct Body {
grant_type: String, grant_type: String,
@ -313,8 +347,8 @@ async fn authenticate(mut req: Request<AppState>) -> tide::Result {
return None; return None;
} }
Some((body.client_id.unwrap(), body.client_secret.unwrap())) Some((body.client_id.unwrap(), body.client_secret.unwrap()))
}); })
let credentials = credentials.ok_or(OAuthError::new( .ok_or(OAuthError::new(
"invalid_client", "invalid_client",
"Credentials not found in Basic auth or in req body", "Credentials not found in Basic auth or in req body",
))?; ))?;
@ -322,9 +356,11 @@ async fn authenticate(mut req: Request<AppState>) -> tide::Result {
// authenticate client // authenticate client
let client_info = req let client_info = req
.state() .state()
.config
.clients .clients
.get(&credentials.0) .get(&credentials.0)
.ok_or(OAuthError::new("invalid_client", "Unknown client"))?; .ok_or(OAuthError::new("invalid_client", "Unknown client"))?;
if ring::constant_time::verify_slices_are_equal( if ring::constant_time::verify_slices_are_equal(
credentials.1.as_bytes(), credentials.1.as_bytes(),
&client_info.client_secret.as_bytes(), &client_info.client_secret.as_bytes(),
@ -335,7 +371,7 @@ async fn authenticate(mut req: Request<AppState>) -> tide::Result {
} }
// check authorization code // 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. // authorization code. It's always the client's fault, so we don't care really.
let code_info = { let code_info = {
let mut auths = req.state().authorizations.lock().await; let mut auths = req.state().authorizations.lock().await;
@ -354,8 +390,7 @@ async fn authenticate(mut req: Request<AppState>) -> tide::Result {
} }
// verify PKCE if the client used it // verify PKCE if the client used it
if code_info.pkce.is_some() { if let Some(pkce) = code_info.pkce {
let pkce = code_info.pkce.unwrap();
let code_verifier = body let code_verifier = body
.code_verifier .code_verifier
.ok_or(OAuthError::new("invalid_request", "PKCE not present"))?; .ok_or(OAuthError::new("invalid_request", "PKCE not present"))?;
@ -364,12 +399,11 @@ async fn authenticate(mut req: Request<AppState>) -> tide::Result {
"S256" => { "S256" => {
let sha_digest = let sha_digest =
ring::digest::digest(&ring::digest::SHA256, code_verifier.as_bytes()); ring::digest::digest(&ring::digest::SHA256, code_verifier.as_bytes());
let a = base64_coder::URL_SAFE_NO_PAD.encode(sha_digest.as_ref()); Ok(base64_coder::URL_SAFE_NO_PAD.encode(sha_digest.as_ref()))
a
} }
_ => pkce.challenge.clone(), // not the best, but since the only other option is "plain" => Ok(pkce.challenge.clone()),
// "plain", then I guess its fine _ => Err(OAuthError::new("invalid_request", "invalid PKCE method")),
}; }?;
// Needs constant time equality checking since we might be comparing plain text. // Needs constant time equality checking since we might be comparing plain text.
// This is a non-issue when using the S265 method // This is a non-issue when using the S265 method
@ -383,16 +417,26 @@ async fn authenticate(mut req: Request<AppState>) -> 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 // give access code
Ok(Response::builder(200).body(json!({ Ok(Response::builder(200).body(json!({
"access_token": "SOME-TOKEN", "access_token": access_token,
"token_type": "Bearer", "token_type": "Bearer",
"expires_in": 600, "expires_in": TOKEN_EXPIRATION,
"id_token": create_id_token(req.state(), &credentials.0, &code_info.uid, code_info.nonce)?, "id_token": create_id_token(req.state(), &credentials.0, &code_info.account, code_info.nonce)?,
"scope": "openid profile email"
})).into()) })).into())
} }
async fn jwks(req: Request<AppState>) -> tide::Result { async fn jwks_endpoint(req: Request<AppState>) -> tide::Result {
let pk: ring::rsa::PublicKeyComponents<Vec<u8>> = req.state().signing_key.public().into(); let pk: ring::rsa::PublicKeyComponents<Vec<u8>> = req.state().signing_key.public().into();
Ok(Response::builder(200) Ok(Response::builder(200)
.body(json!({ .body(json!({
@ -408,35 +452,77 @@ async fn jwks(req: Request<AppState>) -> tide::Result {
} }
async fn configuration_endpoint(req: Request<AppState>) -> tide::Result { async fn configuration_endpoint(req: Request<AppState>) -> tide::Result {
let uri = Url::parse(&req.state().issuer_uri)?; let uri = Url::parse(&req.state().config.issuer_uri)?;
Ok(Response::builder(200) Ok(Response::builder(200)
.body(json!({ .body(json!({
"issuer": uri, "issuer": uri,
"authorization_endpoint": uri.join("/authorize")?, "authorization_endpoint": uri.join("/authorize")?,
"token_endpoint": uri.join("/token")?, "token_endpoint": uri.join("/token")?,
"jwks_uri": uri.join("/jwks")?, "jwks_uri": uri.join("/jwks")?,
"response_types_supported": ["code", "id_token"], "response_types_supported": ["code"],
"subject_types_supported": ["pairwise"], "subject_types_supported": ["pairwise"],
"id_token_signing_alg_values_supported": ["RS256"], "id_token_signing_alg_values_supported": ["RS256"],
})) }))
.into()) .into())
} }
async fn homepage_endpoint(req: Request<AppState>) -> 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<AppState>) -> 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<AppState>) -> 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 struct AuthStore {
pub auths: HashMap<String, Authorization>, pub auths: HashMap<String, Authorization>,
pub expirations: LinkedList<(String, u64)>, pub expirations: LinkedList<(String, u64)>,
} }
#[derive(Clone)] pub struct AppStateRaw {
pub struct AppState { pub config: Config,
pub clients: Arc<HashMap<String, Client>>, pub authorizations: Mutex<AuthStore>,
pub authorizations: Arc<Mutex<AuthStore>>, pub signing_key: ring::rsa::KeyPair,
pub issuer_uri: String, pub total_logins: AtomicUsize,
pub issuer_name: String, pub successful_logins: AtomicUsize,
pub signing_key: Arc<ring::rsa::KeyPair>, pub expired_logins: AtomicUsize,
pub salt: String, pub server_errors: AtomicUsize,
} }
type AppState = Arc<AppStateRaw>;
pub struct PKCE { pub struct PKCE {
pub challenge: String, pub challenge: String,
pub method: String, pub method: String,
@ -444,7 +530,7 @@ pub struct PKCE {
pub struct Authorization { pub struct Authorization {
pub client_id: String, pub client_id: String,
pub uid: String, pub account: String,
pub redirect_uri: String, pub redirect_uri: String,
pub pkce: Option<PKCE>, pub pkce: Option<PKCE>,
pub nonce: Option<String>, pub nonce: Option<String>,
@ -453,43 +539,47 @@ pub struct Authorization {
#[async_std::main] #[async_std::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
// log::start(); log::with_level(log::LevelFilter::Debug);
let mut conf_file = 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 config = Config::from_file(&mut conf_file).await?;
let mut clients: HashMap<String, Client> = HashMap::new(); let bind_address = format!("{}:{}", &config.host, &config.port);
for c in config.clients {
clients.insert(c.0, c.1.try_into()?);
}
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 { let mut app = tide::with_state(Arc::new(AppStateRaw {
clients: Arc::new(clients), config,
authorizations: Arc::new(Mutex::new(AuthStore { authorizations: Mutex::new(AuthStore {
auths: HashMap::new(), auths: HashMap::new(),
expirations: LinkedList::new(), expirations: LinkedList::new(),
})), }),
issuer_uri: config.issuer_uri, signing_key,
issuer_name: config.issuer_name, total_logins: AtomicUsize::new(0),
salt: config.salt, successful_logins: AtomicUsize::new(0),
signing_key: Arc::new( expired_logins: AtomicUsize::new(0),
ring::rsa::KeyPair::from_pkcs8(&key_data)?, server_errors: AtomicUsize::new(0),
), }));
});
app.with(tide::utils::After(error_handler)); app.with(tide::utils::After(error_handler));
app.at("/authorize").get(authorize); app.at("/").get(homepage_endpoint);
app.at("/token").post(authenticate); app.at("/authorize")
app.at("/jwks").get(jwks); .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") app.at("/.well-known/openid-configuration")
.get(configuration_endpoint); .get(configuration_endpoint);
app.at("/new-account").get(create_account_endpoint);
app.at("/metrics").get(metrics_endpoint);
app.listen(format!("{}:{}", config.host, config.port)) auto_serve_dir!(app, "/static", "static");
.await?;
println!("Server started at {}", &bind_address);
app.listen(bind_address).await?;
Ok(()) Ok(())
} }

View file

@ -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"
];

39
static/fonts.css Normal file
View file

@ -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+ */
}

Binary file not shown.

Binary file not shown.

Binary file not shown.