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",
"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",
]

View file

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

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.
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 :)

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>
<meta charset="utf-8" />
<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" />
<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 {
--background: rgb(24, 24, 27);
@ -21,7 +21,6 @@
.issuer-name {
color: var(--primary);
font-weight: bolder;
}
.issuer-name a {
@ -40,9 +39,9 @@
width: 19ch;
border: none;
padding: 0.4rem 1rem;
padding-right: 0;
background: none;
color: inherit;
text-transform: uppercase;
}
@media only screen and (max-width: 40em) {
@ -56,10 +55,13 @@
}
html {
height: 100vmin;
height: 100vh;
}
body {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--background);
color: var(--text);
font-family: Poppins, sans-serif;
@ -67,9 +69,10 @@
box-sizing: border-box;
}
main {
.center {
margin: 20vmin auto 0;
width: min-content;
flex-grow: 1;
}
.client-name {
@ -98,26 +101,23 @@
}
.container button {
display: inline-flex;
align-items: center;
margin: 3px 1rem 0;
margin: 6px 1rem 0 0;
}
button {
button, input[type=submit] {
background: none;
border: none;
color: var(--text);
cursor: pointer;
}
.login-button {
form .login-button {
background: var(--secondary);
padding: 0.8rem;
padding: 0.5rem 0.8rem;
font-weight: bold;
font-size: larger;
width: 100%;
margin: 0.5rem 0 0;
border-radius: 5px;
margin-left: auto;
}
.login-button:hover {
@ -133,6 +133,10 @@
color: inherit;
}
.hidden {
display: none;
}
.info-accounts {
background: var(--caution-darker);
width: 100%;
@ -148,11 +152,12 @@
opacity: 0;
}
button[data-closed="false"] .closed {
button[data-state="password"] .closed {
display: none;
}
button[data-closed="true"] .open {
button[data-state="visible"] .open {
display: none;
}
@ -170,80 +175,147 @@
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>
</head>
<body>
<main>
<h1 class="issuer-name"><a href="/">^NON</a> : {{issuer_name}}</h1>
<h1 lang="en">You are logging into</h1>
<h1 lang="cs" hidden>Přihlašujete se do</h1>
<p class="client-name">{{name}}</p>
<div class="container" id="accICont">
<input id="accInput" inputmode="numeric" autocomplete="current-password" autofocus type="password"
class="account-number-input" placeholder="0000000000000000" maxlength="16" />
<button id="showHideButton" data-closed="false" type="button" onclick="toggleInputType()">
<svg class="open" xmlns="http://www.w3.org/2000/svg" height="24" fill="currentColor"
viewBox="0 -960 960 960" width="24">
<path
d="M480-320q75 0 127.5-52.5T660-500q0-75-52.5-127.5T480-680q-75 0-127.5 52.5T300-500q0 75 52.5 127.5T480-320Zm0-72q-45 0-76.5-31.5T372-500q0-45 31.5-76.5T480-608q45 0 76.5 31.5T588-500q0 45-31.5 76.5T480-392Zm0 192q-146 0-266-81.5T40-500q54-137 174-218.5T480-800q146 0 266 81.5T920-500q-54 137-174 218.5T480-200Zm0-300Zm0 220q113 0 207.5-59.5T832-500q-50-101-144.5-160.5T480-720q-113 0-207.5 59.5T128-500q50 101 144.5 160.5T480-280Z" />
</svg>
<svg class="closed" xmlns="http://www.w3.org/2000/svg" height="24" fill="currentColor" viewBox="0 -960 960 960" width="24">
<path
d="m644-428-58-58q9-47-27-88t-93-32l-58-58q17-8 34.5-12t37.5-4q75 0 127.5 52.5T660-500q0 20-4 37.5T644-428Zm128 126-58-56q38-29 67.5-63.5T832-500q-50-101-143.5-160.5T480-720q-29 0-57 4t-55 12l-62-62q41-17 84-25.5t90-8.5q151 0 269 83.5T920-500q-23 59-60.5 109.5T772-302Zm20 246L624-222q-35 11-70.5 16.5T480-200q-151 0-269-83.5T40-500q21-53 53-98.5t73-81.5L56-792l56-56 736 736-56 56ZM222-624q-29 26-53 57t-41 67q50 101 143.5 160.5T480-280q20 0 39-2.5t39-5.5l-36-38q-11 3-21 4.5t-21 1.5q-75 0-127.5-52.5T300-500q0-11 1.5-21t4.5-21l-84-82Zm319 93Zm-151 75Z" />
</svg>
</button>
</div>
<div>
<button class="login-button" onclick="login()" type="button">
<span lang="en">Log in</span><span lang="cs" hidden>Přihlásit se</span></button>
<p class="generate-text"><a href="javascript:void(0)" onclick="generateAccount()">
<span lang="en">or generate a new account number</span>
<span lang="cs" hidden>nebo vytvořit nové číslo</span>
</a></p>
<div class="center">
<noscript>Enable JS to save the account in your browser</noscript>
<main>
<h1 class="issuer-name"><a href="/" target="_blank">^NON</a> : {{issuer_name}}</h1>
<h1>
<span lang="en">Logging into:</span>
<span lang="cs" hidden>Přihlášení:</span>
<span style="font-weight:normal"> {{client_name}}</span>
</h1>
<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"
viewBox="0 -960 960 960" width="24">
<path
d="M480-320q75 0 127.5-52.5T660-500q0-75-52.5-127.5T480-680q-75 0-127.5 52.5T300-500q0 75 52.5 127.5T480-320Zm0-72q-45 0-76.5-31.5T372-500q0-45 31.5-76.5T480-608q45 0 76.5 31.5T588-500q0 45-31.5 76.5T480-392Zm0 192q-146 0-266-81.5T40-500q54-137 174-218.5T480-800q146 0 266 81.5T920-500q-54 137-174 218.5T480-200Zm0-300Zm0 220q113 0 207.5-59.5T832-500q-50-101-144.5-160.5T480-720q-113 0-207.5 59.5T128-500q50 101 144.5 160.5T480-280Z" />
</svg>
<svg class="closed" xmlns="http://www.w3.org/2000/svg" height="24" fill="currentColor" viewBox="0 -960 960 960" width="24">
<path
d="m644-428-58-58q9-47-27-88t-93-32l-58-58q17-8 34.5-12t37.5-4q75 0 127.5 52.5T660-500q0 20-4 37.5T644-428Zm128 126-58-56q38-29 67.5-63.5T832-500q-50-101-143.5-160.5T480-720q-29 0-57 4t-55 12l-62-62q41-17 84-25.5t90-8.5q151 0 269 83.5T920-500q-23 59-60.5 109.5T772-302Zm20 246L624-222q-35 11-70.5 16.5T480-200q-151 0-269-83.5T40-500q21-53 53-98.5t73-81.5L56-792l56-56 736 736-56 56ZM222-624q-29 26-53 57t-41 67q50 101 143.5 160.5T480-280q20 0 39-2.5t39-5.5l-36-38q-11 3-21 4.5t-21 1.5q-75 0-127.5-52.5T300-500q0-11 1.5-21t4.5-21l-84-82Zm319 93Zm-151 75Z" />
</svg>
</button>
</div>
<div class="login-action-container">
<a id="newAccountButton" href="/new-account" target="_blank">
<span class="generate" lang="en">Generate new account</span>
<span class="generate" lang="cs" hidden>Generovat nový účet</span>
<span class="login-once hidden" lang="en">Log in once</span>
<span class="login-once hidden" lang="cs" hidden>Přihlásit se jednou</span>
</a>
<button id="loginButton" class="login-button">
<!-- this will get changed to "Log in" by JS -->
<span id="loginButtonText" lang="en">Log in once</span>
<span lang="cs" hidden>Přihlásit se</span>
</button>
</div>
</form>
<p id="infoBox" hidden class="info-accounts hidden">
<span lang="en"><b>Careful!</b> Your account number is the only way to access your accounts. Keep it
somewhere safe and out of sight!</span>
<span lang="cs" hidden><b>Pozor!</b> Toto číslo 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>
<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>
</div>
</main>
<p class="info-accounts notice">{{notice}}</p>
</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>
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();
return;
}
});
accInput.addEventListener("input", ev => {
if (accInput.value.length == accInput.maxLength) {
const p = [...accInput.value.replace(/ +/g, "")];
const payload = p.slice(0, p.length - 1);
accICont.dataset.valid = calculateLuhnPrime(payload) == p[p.length - 1];
} else {
accICont.dataset.valid = "pending";
accInput.value = accInput.value.replace(/ +/g, "");
if (ev.submitter === loginButton) {
window.localStorage.setItem("code", accInput.value);
}
if (accInput.type == "password") return;
formatInput();
});
function formatInput() {
const caret = accInput.selectionStart;
const digitsBeforeCaret = accInput.value.slice(0, caret).replace(/ +/g, "").length;
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) {
const digitsBeforeCaret = accInput.value.slice(0, caret).replace(/ +/g, "").length;
const offset = Math.floor(digitsBeforeCaret / 4);
accInput.value = formatted;
accInput.selectionStart = digitsBeforeCaret + offset;
accInput.selectionEnd = digitsBeforeCaret + offset;
}
}
function toggleInputType() {
showHideButton.dataset.closed = showHideButton.dataset.closed == "true" ? "false" : "true"
if (accInput.type == "password") {
showCodeButton.dataset.state = showCodeButton.dataset.state == "visible" ? "password" : "visible";
if (showCodeButton.dataset.state == "visible") {
accInput.maxLength = 19;
accInput.placeholder = "0000 0000 0000 0000";
formatInput();
@ -256,50 +328,91 @@
}
}
function login() {
if (accICont.dataset.valid != "true") return;
const digits = accInput.value.replace(/ +/g, "");
window.localStorage.setItem("code", digits);
let params = new URLSearchParams(window.location.search);
params.set("uid", digits);
window.location.search = params;
function updateSecondaryAction() {
newAccountButton.target = "_self";
if (accInputCont.dataset.valid == 'true') {
document.querySelectorAll('.generate').forEach(el => el.classList.add('hidden'))
document.querySelectorAll('.login-once').forEach(el => el.classList.remove('hidden'))
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) {
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);
return sum % 10;
function calculateChecksum(payload) {
const sum = payload.map(parseDigit).reduce((a, b, i) => a + (i % 2 == 0 ? 2*b : b), 0);
return makeDigit(sum % 36);
}
function generateAccount() {
accInput.type != "text" && toggleInputType();
let code = "a";
let array = new Uint8Array(15);
window.crypto.getRandomValues(array);
let accNumber = [...array].map(n => String(Math.floor(n / 255 * 10))).join("");
// 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);
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();
updateSecondaryAction();
infoBox.hidden = false;
setTimeout(() => infoBox.classList.toggle("hidden", false), 0);
}
const savedCode = window.localStorage.getItem("code");
if (savedCode) {
accInput.value = savedCode;
accICont.dataset.valid = true;
function forget() {
window.localStorage.removeItem("code");
window.location.reload();
}
// show JS-only features
showCodeButton.removeAttribute('hidden');
loginButtonText.innerText = "Log in";
// 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());
const savedCode = window.localStorage.getItem("code");
if (savedCode) {
accInput.value = savedCode;
accInputCont.dataset.valid = true;
forgetAccountButton.hidden = false;
}
updateSecondaryAction();
</script>
</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::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<String>,
name: String,
client_secret: String,
redirect_uris: Vec<String>,
}
#[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<String, Client>,
issuer_uri: String,
issuer_name: String,
rsa_key_file: String,
salt: String,
}
impl Config {
pub async fn from_file(file: &mut File) -> Result<Config> {
let mut buf = String::new();
file.read_to_string(&mut buf).await?;
match toml::from_str::<Config>(&buf) {
match serde_yaml::from_str::<Config>(&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::<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)]
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::<OAuthError>() {
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<String>,
code_challenge: Option<String>,
code_challenge_method: Option<String>,
nonce: Option<String>,
) -> anyhow::Result<Value> {
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<AppState>) -> 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<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(
app_state: &AppState,
client_id: &str,
uid: &str,
normalized_account: &str,
nonce: Option<String>,
) -> anyhow::Result<String> {
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<AppState>) -> tide::Result {
async fn authenticate_endpoint(mut req: Request<AppState>) -> tide::Result {
#[derive(Deserialize)]
struct Body {
grant_type: String,
@ -313,18 +347,20 @@ async fn authenticate(mut req: Request<AppState>) -> 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<AppState>) -> 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<AppState>) -> 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<AppState>) -> 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<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
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<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();
Ok(Response::builder(200)
.body(json!({
@ -408,35 +452,77 @@ async fn jwks(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)
.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<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 auths: HashMap<String, Authorization>,
pub expirations: LinkedList<(String, u64)>,
}
#[derive(Clone)]
pub struct AppState {
pub clients: Arc<HashMap<String, Client>>,
pub authorizations: Arc<Mutex<AuthStore>>,
pub issuer_uri: String,
pub issuer_name: String,
pub signing_key: Arc<ring::rsa::KeyPair>,
pub salt: String,
pub struct AppStateRaw {
pub config: Config,
pub authorizations: Mutex<AuthStore>,
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<AppStateRaw>;
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<PKCE>,
pub nonce: Option<String>,
@ -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<String, Client> = 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(())
}

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.