Prechádzať zdrojové kódy

Support external authentication via github.

Currently the association must be added manually to the database.
Kestrel 10 mesiacov pred
rodič
commit
4d45d48eb7
16 zmenil súbory, kde vykonal 722 pridanie a 54 odobranie
  1. 2 0
      .gitignore
  2. 297 1
      Cargo.lock
  3. 2 0
      Cargo.toml
  4. 4 2
      src/cli.rs
  5. 17 0
      src/cli/user.rs
  6. 4 7
      src/config.rs
  7. 79 0
      src/ext.rs
  8. 12 0
      src/ext/generic_oidc.rs
  9. 212 0
      src/ext/github.rs
  10. 1 0
      src/main.rs
  11. 10 2
      src/schema.rs
  12. 31 8
      src/server.rs
  13. 12 8
      src/server/oidc.rs
  14. 30 24
      src/server/session.rs
  15. 4 2
      src/user.rs
  16. 5 0
      tmpl/id_v1_login.tmpl

+ 2 - 0
.gitignore

@@ -2,3 +2,5 @@
 /uidc
 /uidc.db
 .*.sw?
+/uidc-gh.toml
+/tmp

+ 297 - 1
Cargo.lock

@@ -253,7 +253,7 @@ dependencies = [
  "polling 2.8.0",
  "rustix 0.37.27",
  "slab",
- "socket2",
+ "socket2 0.4.10",
  "waker-fn",
 ]
 
@@ -548,6 +548,18 @@ version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
 
+[[package]]
+name = "bytes"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38"
+
+[[package]]
+name = "bytes"
+version = "1.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3"
+
 [[package]]
 name = "cc"
 version = "1.1.15"
@@ -780,6 +792,37 @@ dependencies = [
  "cipher",
 ]
 
+[[package]]
+name = "curl"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e2161dd6eba090ff1594084e95fd67aeccf04382ffea77999ea94ed42ec67b6"
+dependencies = [
+ "curl-sys",
+ "libc",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "socket2 0.5.7",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "curl-sys"
+version = "0.4.76+curl-8.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00462dbe9cbb9344e1b2be34d9094d74e3b8aac59a883495b335eafd02e25120"
+dependencies = [
+ "cc",
+ "libc",
+ "libnghttp2-sys",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "deranged"
 version = "0.3.11"
@@ -821,6 +864,15 @@ version = "1.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
 
+[[package]]
+name = "encoding_rs"
+version = "0.8.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59"
+dependencies = [
+ "cfg-if 1.0.0",
+]
+
 [[package]]
 name = "env_logger"
 version = "0.10.2"
@@ -929,6 +981,23 @@ dependencies = [
  "web-sys",
 ]
 
+[[package]]
+name = "flume"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bebadab126f8120d410b677ed95eee4ba6eb7c6dd8e34a5ec88a08050e26132"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spinning_top",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
 [[package]]
 name = "form_urlencoded"
 version = "1.2.1"
@@ -998,6 +1067,12 @@ dependencies = [
  "syn 2.0.77",
 ]
 
+[[package]]
+name = "futures-sink"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
+
 [[package]]
 name = "futures-task"
 version = "0.3.30"
@@ -1011,8 +1086,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
 dependencies = [
  "futures-core",
+ "futures-io",
  "futures-macro",
  "futures-task",
+ "memchr",
  "pin-project-lite 0.2.14",
  "pin-utils",
  "slab",
@@ -1158,15 +1235,28 @@ dependencies = [
  "digest 0.10.7",
 ]
 
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes 1.7.2",
+ "fnv",
+ "itoa",
+]
+
 [[package]]
 name = "http-client"
 version = "6.5.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1947510dc91e2bf586ea5ffb412caad7673264e14bb39fb9078da114a94ce1a5"
 dependencies = [
+ "async-std",
  "async-trait",
  "cfg-if 1.0.0",
  "http-types",
+ "isahc",
  "log",
 ]
 
@@ -1290,6 +1380,29 @@ version = "1.70.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
 
+[[package]]
+name = "isahc"
+version = "0.9.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2948a0ce43e2c2ef11d7edf6816508998d99e13badd1150be0914205df9388a"
+dependencies = [
+ "bytes 0.5.6",
+ "crossbeam-utils",
+ "curl",
+ "curl-sys",
+ "flume",
+ "futures-lite 1.13.0",
+ "http",
+ "log",
+ "once_cell",
+ "slab",
+ "sluice",
+ "tracing",
+ "tracing-futures",
+ "url",
+ "waker-fn",
+]
+
 [[package]]
 name = "itertools"
 version = "0.12.1"
@@ -1350,6 +1463,16 @@ version = "0.2.158"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439"
 
+[[package]]
+name = "libnghttp2-sys"
+version = "0.1.10+1.61.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "959c25552127d2e1fa72f0e52548ec04fc386e827ba71a7bd01db46a447dc135"
+dependencies = [
+ "cc",
+ "libc",
+]
+
 [[package]]
 name = "libsqlite3-sys"
 version = "0.28.0"
@@ -1361,6 +1484,18 @@ dependencies = [
  "vcpkg",
 ]
 
+[[package]]
+name = "libz-sys"
+version = "1.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
 [[package]]
 name = "linux-raw-sys"
 version = "0.3.8"
@@ -1427,6 +1562,22 @@ dependencies = [
  "syn 1.0.109",
 ]
 
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
 [[package]]
 name = "mio"
 version = "0.8.11"
@@ -1485,6 +1636,24 @@ version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
 
+[[package]]
+name = "openssl-probe"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
 [[package]]
 name = "parking"
 version = "2.2.0"
@@ -1933,6 +2102,12 @@ dependencies = [
  "windows-sys 0.52.0",
 ]
 
+[[package]]
+name = "rustversion"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6"
+
 [[package]]
 name = "ryu"
 version = "1.0.18"
@@ -1948,6 +2123,15 @@ dependencies = [
  "winapi-util",
 ]
 
+[[package]]
+name = "schannel"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9aaafd5a2b6e3d657ff009d82fbd630b6bd54dd4eb06f21693925cdf80f9b8b"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
 [[package]]
 name = "scopeguard"
 version = "1.2.0"
@@ -2158,6 +2342,17 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "sluice"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5"
+dependencies = [
+ "async-channel 1.9.0",
+ "futures-core",
+ "futures-io",
+]
+
 [[package]]
 name = "smallvec"
 version = "1.13.2"
@@ -2191,6 +2386,16 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "socket2"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "spin"
 version = "0.5.2"
@@ -2203,6 +2408,15 @@ version = "0.9.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
 
+[[package]]
+name = "spinning_top"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b9eb1a2f4c41445a3a0ff9abc5221c5fcd28e1f13cd7c0397706f9ac938ddb0"
+dependencies = [
+ "lock_api",
+]
+
 [[package]]
 name = "standback"
 version = "0.2.17"
@@ -2267,12 +2481,57 @@ version = "0.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
 
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.77",
+]
+
 [[package]]
 name = "subtle"
 version = "2.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
 
+[[package]]
+name = "surf"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "718b1ae6b50351982dedff021db0def601677f2120938b070eadb10ba4038dd7"
+dependencies = [
+ "async-std",
+ "async-trait",
+ "cfg-if 1.0.0",
+ "encoding_rs",
+ "futures-util",
+ "getrandom 0.2.15",
+ "http-client",
+ "http-types",
+ "log",
+ "mime_guess",
+ "once_cell",
+ "pin-project-lite 0.2.14",
+ "serde",
+ "serde_json",
+ "web-sys",
+]
+
 [[package]]
 name = "sval"
 version = "2.13.0"
@@ -2549,15 +2808,41 @@ version = "0.1.40"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
 dependencies = [
+ "log",
  "pin-project-lite 0.2.14",
+ "tracing-attributes",
  "tracing-core",
 ]
 
+[[package]]
+name = "tracing-attributes"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.77",
+]
+
 [[package]]
 name = "tracing-core"
 version = "0.1.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "tracing-futures"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
+dependencies = [
+ "pin-project",
+ "tracing",
+]
 
 [[package]]
 name = "typeid"
@@ -2603,11 +2888,22 @@ dependencies = [
  "sha1 0.10.6",
  "sha2 0.10.8",
  "smol",
+ "strum",
+ "surf",
  "tide",
  "time 0.3.36",
  "toml",
 ]
 
+[[package]]
+name = "unicase"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
+dependencies = [
+ "version_check",
+]
+
 [[package]]
 name = "unicode-bidi"
 version = "0.3.15"

+ 2 - 0
Cargo.toml

@@ -14,6 +14,7 @@ lazy_static = "1.4.0"
 time = { version = "0.3", features = ["std", "formatting"] }
 itertools = "0.12"
 glob = "0.3"
+strum = { version = "0.26", features = ["derive"] }
 
 # crypto
 ring = { version = "0.16", features = ["std"] }
@@ -36,6 +37,7 @@ tide = { version = "0.16.0" }
 handlebars = { version = "4.3", features = ["dir_source"] }
 serde_json = "1.0"
 jsonwebtoken = "9.3"
+surf = { version = "2.3" }
 
 # CLI dependencies
 clap = { version = "4.5", features = ["derive", "env", "string"] }

+ 4 - 2
src/cli.rs

@@ -86,8 +86,10 @@ struct RunArgs {
 
 impl RootArgs {
     async fn run(self) -> Result<(), UIDCError> {
-        let config_contents = std::fs::read_to_string(self.config_path.as_str()).expect("couldn't open configuration file");
-        let config : Config = toml::from_str(config_contents.as_str()).expect("couldn't parse configuration file");
+        let config_contents = std::fs::read_to_string(self.config_path.as_str())
+            .expect("couldn't open configuration file");
+        let config: Config =
+            toml::from_str(config_contents.as_str()).expect("couldn't parse configuration file");
 
         if let Command::Init = self.command {
             return self.init(config).await;

+ 17 - 0
src/cli/user.rs

@@ -16,6 +16,10 @@ pub enum UserCommands {
         #[clap(short = 't', long, action = clap::ArgAction::Count)]
         totp: u8,
     },
+    RegisterExternalAuth {
+        username: String,
+        provider: schema::ExternalAuthProvider,
+    },
 }
 
 impl microrm::cli::EntityInterface for UserInterface {
@@ -34,6 +38,7 @@ impl microrm::cli::EntityInterface for UserInterface {
                 query_ctx.insert(schema::User {
                     realm: ctx.id(),
                     username,
+                    pending_external_auths: Default::default(),
                     auth: Default::default(),
                     groups: Default::default(),
                 })?;
@@ -79,6 +84,18 @@ impl microrm::cli::EntityInterface for UserInterface {
                     user.set_new_totp(new_secret.as_slice())?;
                 }
             }
+            UserCommands::RegisterExternalAuth { username, provider } => {
+                let mut user = query_ctx
+                    .with(schema::User::Username, &username)
+                    .first()
+                    .get()?
+                    .ok_or(Self::Error::no_such_entity("user", username))?;
+
+                user.pending_external_auths.as_mut().push(provider);
+                user.pending_external_auths.as_mut().sort();
+                user.pending_external_auths.as_mut().dedup();
+                user.sync().expect("couldn't sync user model");
+            }
         }
 
         Ok(())

+ 4 - 7
src/config.rs

@@ -1,10 +1,6 @@
-use serde::{Deserialize, Serialize};
+use serde::Deserialize;
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct GithubConfig {
-    pub client_id: String,
-    pub client_secret: String,
-}
+use crate::ext::{GithubConfig, OIDCConfig};
 
 fn default_auth_token_expiry() -> u64 {
     600
@@ -18,7 +14,7 @@ fn default_refresh_token_expiry() -> u64 {
     3600
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Deserialize)]
 pub struct Config {
     pub db_path: String,
 
@@ -34,4 +30,5 @@ pub struct Config {
     pub refresh_token_expiry: u64,
 
     pub github: Option<GithubConfig>,
+    pub oidc: Option<OIDCConfig>,
 }

+ 79 - 0
src/ext.rs

@@ -0,0 +1,79 @@
+use std::future::Future;
+
+use crate::{
+    config::Config,
+    schema::{self, UIDCDatabase},
+    server::{self, SessionHelper, UIDCRequest},
+};
+
+mod generic_oidc;
+mod github;
+
+pub trait ExternalAuthenticator {
+    fn build(db: &UIDCDatabase, config: &Config) -> Option<Self>
+    where
+        Self: Sized;
+    fn register_routes(&'static self, server: &mut tide::Server<server::ServerStateWrapper>);
+
+    fn generate_login_url(&self, realm: &str, redirect: &str) -> String;
+    fn generate_registration_url(&self, realm: &str, redirect: &str) -> String;
+    fn extract_login_state(&self, req: UIDCRequest) -> impl Future<Output = tide::Response>;
+
+    fn handle_matching_login(
+        &self,
+        req: UIDCRequest,
+        user: schema::UserID,
+        redirect: &str,
+    ) -> tide::Response {
+        let sh = SessionHelper::new(&req);
+
+        let Ok((resp, cookie)) = sh.get_or_build_session(&req) else {
+            return tide::Response::builder(500)
+                .body("error while building session")
+                .build();
+        };
+
+        let realm_id = sh.get_realm().unwrap().id();
+
+        // remove any existing authentication for this realm just in case
+        resp.auth
+            .with(schema::SessionAuth::Realm, realm_id)
+            .first()
+            .delete()
+            .expect("couldn't remove existing authentication");
+        resp.auth
+            .insert(schema::SessionAuth {
+                realm: realm_id,
+                user: Some(user),
+                pending_user: None,
+                pending_challenges: vec![].into_serialized(),
+            })
+            .expect("couldn't insert new authentication");
+
+        let mut resp: tide::Response = tide::Redirect::see_other(redirect).into();
+
+        if let Some(cookie) = cookie {
+            resp.insert_cookie(cookie);
+        }
+
+        return resp;
+    }
+
+    fn handle_no_mapping(&self, req: UIDCRequest, redirect: String) -> tide::Response {
+        let sh = SessionHelper::new(&req);
+
+        return sh.render_login_from_auth(
+            tide::Response::new(200),
+            redirect,
+            None,
+            Some("Github user not associated with any local user.".to_string()),
+        );
+    }
+}
+
+pub use generic_oidc::{OIDCAuthenticator, OIDCConfig};
+pub use github::{GithubAuthenticator, GithubConfig};
+use microrm::{
+    prelude::{Insertable, Queryable},
+    schema::Serializable,
+};

+ 12 - 0
src/ext/generic_oidc.rs

@@ -0,0 +1,12 @@
+use serde::Deserialize;
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct OIDCConfig {
+    pub name: String,
+    pub base_url: String,
+    pub client_id: String,
+    pub client_secret: Option<String>,
+}
+
+pub struct OIDCAuthenticator;

+ 212 - 0
src/ext/github.rs

@@ -0,0 +1,212 @@
+use microrm::{
+    prelude::{Insertable, Queryable},
+    schema::Serializable,
+};
+use serde::Deserialize;
+
+use crate::{
+    config::Config,
+    schema::{self, UIDCDatabase},
+    server::{ServerStateWrapper, SessionHelper, UIDCRequest},
+};
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct GithubConfig {
+    pub login_url: Option<String>,
+    pub token_url: Option<String>,
+    pub api_base: Option<String>,
+
+    pub client_id: String,
+    pub client_secret: String,
+}
+
+const DEFAULT_LOGIN_URL: &'static str = "https://github.com/login/oauth/authorize";
+const DEFAULT_TOKEN_URL: &'static str = "https://github.com/login/oauth/access_token";
+const DEFAULT_API_BASE: &'static str = "https://api.github.com";
+
+#[derive(Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum CallbackRequestType {
+    Login,
+    Register,
+}
+
+pub struct GithubAuthenticator {
+    base_url: String,
+    config: GithubConfig,
+}
+
+impl super::ExternalAuthenticator for GithubAuthenticator {
+    fn build(_db: &UIDCDatabase, config: &Config) -> Option<Self> {
+        config.github.as_ref().map(|ghc| Self {
+            base_url: config.base_url.clone(),
+            config: ghc.clone(),
+        })
+    }
+
+    fn register_routes(&'static self, server: &mut tide::Server<ServerStateWrapper>) {
+        server
+            .at("/:realm/github_return")
+            .get(|req: UIDCRequest| async { Ok(self.extract_login_state(req).await) });
+    }
+
+    fn generate_login_url(&self, realm: &str, redirect: &str) -> String {
+        let return_url = format!(
+            "{base_url}/{realm}/github_return?redirect={redirect}&mode=login",
+            base_url = self.base_url,
+        );
+
+        tide::http::Url::parse_with_params(
+            self.config
+                .login_url
+                .as_deref()
+                .unwrap_or(DEFAULT_LOGIN_URL),
+            &[
+                ("client_id", self.config.client_id.as_str()),
+                ("redirect_uri", return_url.as_str()),
+            ],
+        )
+        .expect("couldn't generate login url for github")
+        .into()
+    }
+
+    fn generate_registration_url(&self, realm: &str, redirect: &str) -> String {
+        let return_url = format!(
+            "{base_url}/{realm}/github_return?redirect={redirect}&mode=register",
+            base_url = self.base_url,
+        );
+
+        tide::http::Url::parse_with_params(
+            self.config
+                .login_url
+                .as_deref()
+                .unwrap_or(DEFAULT_LOGIN_URL),
+            &[
+                ("client_id", self.config.client_id.as_str()),
+                ("redirect_uri", return_url.as_str()),
+            ],
+        )
+        .expect("couldn't generate login url for github")
+        .into()
+    }
+
+    fn extract_login_state(
+        &self,
+        req: UIDCRequest,
+    ) -> impl smol::prelude::Future<Output = tide::Response> {
+        async move {
+            let state = req.state();
+            let realm = req.param("realm").unwrap();
+
+            #[derive(Deserialize)]
+            struct Query {
+                code: String,
+                redirect: String,
+                mode: CallbackRequestType,
+            }
+            let Ok(query) = req.query::<Query>() else {
+                return tide::Response::builder(400)
+                    .body("Query string invalid.")
+                    .build();
+            };
+
+            #[derive(Deserialize)]
+            struct TokenResponse {
+                access_token: String,
+            }
+
+            let auth = surf::http::auth::BasicAuth::new(
+                self.config.client_id.as_str(),
+                self.config.client_secret.as_str(),
+            );
+
+            let resp: TokenResponse = match state
+                .client
+                .post(
+                    tide::http::Url::parse_with_params(
+                        self.config
+                            .token_url
+                            .as_deref()
+                            .unwrap_or(DEFAULT_TOKEN_URL),
+                        &[
+                            ("client_id", self.config.client_id.as_str()),
+                            ("client_secret", self.config.client_secret.as_str()),
+                            ("code", query.code.as_str()),
+                        ],
+                    )
+                    .expect("couldn't generate token url for github"),
+                )
+                .header(auth.name(), auth.value())
+                .content_type(surf::http::mime::FORM)
+                .recv_form()
+                .await
+            {
+                Ok(resp) => resp,
+                Err(err) => {
+                    return tide::Response::builder(500)
+                        .body(format!("could not parse Github response for token: {err}"))
+                        .build()
+                }
+            };
+
+            let atoken = resp.access_token;
+
+            #[derive(Deserialize)]
+            struct UserInfoResponse {
+                id: i64,
+            }
+
+            let resp: UserInfoResponse = match state
+                .client
+                .get(format!(
+                    "{base}/user",
+                    base = self.config.api_base.as_deref().unwrap_or(DEFAULT_API_BASE)
+                ))
+                .header("Authorization", format!("Bearer {atoken}"))
+                .content_type(surf::http::mime::JSON)
+                .recv_json()
+                .await
+            {
+                Ok(resp) => resp,
+                Err(err) => {
+                    return tide::Response::builder(500)
+                        .body(format!("could not parse Github response for token: {err}"))
+                        .build()
+                }
+            };
+
+            let user_id = resp.id.to_string();
+
+            let Some(realm) = state.db.realms.keyed(realm).get().ok().flatten() else {
+                return tide::Response::builder(404).body("no such realm").build();
+            };
+
+            let external_auth_map = realm
+                .external_auth
+                .keyed((
+                    &user_id,
+                    schema::ExternalAuthProvider::Github.into_serialized(),
+                ))
+                .get()
+                .ok()
+                .flatten();
+
+            match (query.mode, external_auth_map) {
+                (CallbackRequestType::Login, Some(map)) => {
+                    return self.handle_matching_login(
+                        req,
+                        map.internal_user_id,
+                        query.redirect.as_str(),
+                    );
+                }
+                (CallbackRequestType::Login, None) => {
+                    return self.handle_no_mapping(req, query.redirect);
+                }
+                (CallbackRequestType::Register, _) => {
+                    todo!()
+                }
+            }
+        }
+    }
+}

+ 1 - 0
src/main.rs

@@ -5,6 +5,7 @@ mod client;
 mod client_management;
 mod config;
 mod error;
+mod ext;
 mod key;
 mod realm;
 mod schema;

+ 10 - 2
src/schema.rs

@@ -1,5 +1,6 @@
 pub use microrm::prelude::{Database, Entity};
 use serde::{Deserialize, Serialize};
+use strum::EnumString;
 
 use crate::key::KeyType;
 
@@ -17,6 +18,7 @@ pub struct Session {
 
 #[derive(Entity)]
 pub struct SessionAuth {
+    #[key]
     pub realm: RealmID,
 
     pub user: Option<UserID>,
@@ -25,7 +27,7 @@ pub struct SessionAuth {
     pub pending_challenges: microrm::Serialized<Vec<AuthChallengeType>>,
 }
 
-#[derive(Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize, Debug)]
+#[derive(Clone, PartialEq, PartialOrd, Serialize, Deserialize, Debug)]
 pub enum AuthChallengeType {
     Username,
     Password,
@@ -94,6 +96,9 @@ pub struct User {
     pub realm: RealmID,
     #[key]
     pub username: String,
+
+    pub pending_external_auths: microrm::Serialized<Vec<ExternalAuthProvider>>,
+
     pub auth: microrm::RelationMap<AuthChallenge>,
     pub groups: microrm::RelationDomain<UserGroupRelation>,
 }
@@ -172,9 +177,12 @@ pub struct Scope {
 // External (social) authentication
 // ----------------------------------------------------------------------
 
-#[derive(Clone, Debug, Serialize, Deserialize)]
+#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Serialize, Deserialize, EnumString)]
+#[strum(serialize_all = "snake_case")]
+#[serde(rename_all = "snake_case")]
 pub enum ExternalAuthProvider {
     Github,
+    GenericOIDC,
 }
 
 #[derive(Clone, Entity)]

+ 31 - 8
src/server.rs

@@ -1,22 +1,39 @@
-use crate::{config, realm, schema, UIDCError};
+use crate::{
+    config,
+    ext::{ExternalAuthenticator, GithubAuthenticator},
+    realm, schema, UIDCError,
+};
 
 mod oidc;
 mod session;
 mod um;
 
+pub use session::SessionHelper;
+
 pub struct ServerState {
-    config: config::Config,
-    db: schema::UIDCDatabase,
-    templates: handlebars::Handlebars<'static>,
-    realms: realm::RealmCache,
+    pub config: config::Config,
+    pub db: schema::UIDCDatabase,
+    pub templates: handlebars::Handlebars<'static>,
+    pub realms: realm::RealmCache,
+    pub client: surf::Client,
+    pub github_auth: Option<GithubAuthenticator>,
 }
 
-#[derive(Clone)]
-struct ServerStateWrapper {
+#[derive(Copy, Clone)]
+pub struct ServerStateWrapper {
     core: &'static ServerState,
 }
 
-async fn index(req: tide::Request<ServerStateWrapper>) -> tide::Result<tide::Response> {
+impl std::ops::Deref for ServerStateWrapper {
+    type Target = ServerState;
+    fn deref(&self) -> &Self::Target {
+        self.core
+    }
+}
+
+pub type UIDCRequest = tide::Request<ServerStateWrapper>;
+
+async fn index(req: UIDCRequest) -> tide::Result<tide::Response> {
     let shelper = session::SessionHelper::new(&req);
 
     let realm = shelper.get_realm()?;
@@ -56,10 +73,12 @@ pub async fn run_server(
     port: u16,
 ) -> Result<(), UIDCError> {
     let core_state = Box::leak(Box::new(ServerState {
+        github_auth: GithubAuthenticator::build(&db, &config),
         realms: realm::RealmCache::new(config.clone(), db.clone()),
         config,
         db,
         templates: handlebars::Handlebars::new(),
+        client: surf::client(),
     }));
 
     core_state.templates.set_dev_mode(true);
@@ -89,6 +108,10 @@ pub async fn run_server(
     oidc::oidc_server(app.at("/:realm/"));
     um::um_server(app.at("/:realm/"));
 
+    if let Some(gh) = &core_state.github_auth {
+        gh.register_routes(&mut app);
+    }
+
     app.listen((bind, port))
         .await
         .map_err(|_| UIDCError::Abort("couldn't listen on port"))?;

+ 12 - 8
src/server/oidc.rs

@@ -134,17 +134,21 @@ async fn jwks(request: Request) -> tide::Result<tide::Response> {
 async fn discovery_config(request: Request) -> tide::Result<tide::Response> {
     let server_config = &request.state().core.config;
     let realm_name = request.param("realm").unwrap();
-    let base_url = format!(
-        "{}/{}",
-        server_config.base_url,
-        realm_name
-    );
-
-    let Some(realm) = &request.state().core.db.realms.keyed(realm_name).first().get()? else {
+    let base_url = format!("{}/{}", server_config.base_url, realm_name);
+
+    let Some(_realm) = &request
+        .state()
+        .core
+        .db
+        .realms
+        .keyed(realm_name)
+        .first()
+        .get()?
+    else {
         return Ok(tide::Response::builder(404)
             .header(tide::http::headers::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
             .body("No such realm")
-            .build())
+            .build());
     };
 
     let config_response = serde_json::json!({

+ 30 - 24
src/server/session.rs

@@ -1,11 +1,12 @@
-use crate::{schema, user::UserExt, UIDCError};
+use crate::{ext::ExternalAuthenticator, schema, user::UserExt, UIDCError};
 use microrm::{prelude::*, schema::Stored};
 use serde::Deserialize;
 use tide::http::Cookie;
 
-pub(super) struct SessionHelper<'l> {
-    db: &'l schema::UIDCDatabase,
-    tmpl: &'l handlebars::Handlebars<'l>,
+use super::ServerState;
+
+pub struct SessionHelper<'l> {
+    state: &'l ServerState,
     realm_str: &'l str,
 }
 
@@ -16,14 +17,14 @@ const SESSION_COOKIE_NAME: &str = "uidc_session";
 impl<'l> SessionHelper<'l> {
     pub fn new(req: &'l Request) -> Self {
         Self {
-            db: &req.state().core.db,
-            tmpl: &req.state().core.templates,
+            state: req.state(),
             realm_str: req.param("realm").expect("no realm param?"),
         }
     }
 
     pub fn get_realm(&self) -> tide::Result<Stored<schema::Realm>> {
-        self.db
+        self.state
+            .db
             .realms
             .keyed(self.realm_str)
             .get()?
@@ -39,7 +40,7 @@ impl<'l> SessionHelper<'l> {
             .expose();
         let session_id = base64::encode_config(session_id, base64::URL_SAFE_NO_PAD);
 
-        let session = self.db.sessions.insert_and_return(schema::Session {
+        let session = self.state.db.sessions.insert_and_return(schema::Session {
             session_id: session_id.clone(),
             auth: Default::default(),
             expiry: time::OffsetDateTime::now_utc() + time::Duration::minutes(10),
@@ -62,7 +63,8 @@ impl<'l> SessionHelper<'l> {
 
     pub fn get_session(&self, req: &Request) -> Option<schema::Session> {
         req.cookie(SESSION_COOKIE_NAME).and_then(|sid| {
-            self.db
+            self.state
+                .db
                 .sessions
                 .keyed(sid.value())
                 .get()
@@ -110,7 +112,7 @@ impl<'l> SessionHelper<'l> {
 }
 
 impl<'l> SessionHelper<'l> {
-    fn render_login_from_auth(
+    pub fn render_login_from_auth(
         &self,
         mut response: tide::Response,
         redirect: String,
@@ -119,7 +121,7 @@ impl<'l> SessionHelper<'l> {
     ) -> tide::Response {
         let to_present: Option<schema::AuthChallengeType> = match auth {
             None => Some(schema::AuthChallengeType::Username),
-            Some(auth) => auth.pending_challenges.as_ref().first().copied(),
+            Some(auth) => auth.pending_challenges.as_ref().first().cloned(),
         };
 
         if let Some(to_present) = to_present {
@@ -138,22 +140,26 @@ impl<'l> SessionHelper<'l> {
         error_msg: Option<String>,
     ) -> tide::Response {
         let do_challenge = |ty, ch| {
-            self.tmpl
+            let gh = &self.state.github_auth;
+            self.state
+                .templates
                 .render(
                     "id_v1_login",
                     &serde_json::json!(
                         {
                             "challenge":
                                 format!(r#"
-                            <td class="challenge-type">
-                                <input type="hidden" name="challenge_type" value="{:?}" />
-                                {}
-                            </td>
-                            <td class="challenge-content">{}</td>
-                            "#,
+                                    <td class="challenge-type">
+                                        <input type="hidden" name="challenge_type" value="{:?}" />
+                                        {}
+                                    </td>
+                                    <td class="challenge-content">{}</td>
+                                    "#,
                                     to_present, ty, ch),
                             "redirect": redirect,
-                            "error_msg": error_msg.iter().collect::<Vec<_>>()
+                            "error_msg": error_msg.iter().collect::<Vec<_>>(),
+                            "show_gh_login": gh.is_some(),
+                            "gh_login_url": gh.as_ref().map(|gh| gh.generate_login_url(self.realm_str, redirect.as_str())).unwrap_or(String::new()),
                         }
                     ),
                 )
@@ -261,10 +267,10 @@ async fn v1_login_post(mut req: Request) -> tide::Result<tide::Response> {
     // check that the response matches what we're expecting next
     let to_be_presented: Option<schema::AuthChallengeType> = match &auth {
         None => Some(schema::AuthChallengeType::Username),
-        Some(auth) => auth.pending_challenges.as_ref().first().copied(),
+        Some(auth) => auth.pending_challenges.as_ref().first().cloned(),
     };
 
-    if to_be_presented != Some(challenge) {
+    if to_be_presented.as_ref() != Some(&challenge) {
         Err(tide::Error::from_str(400, "Unexpected challenge type"))?
     }
 
@@ -309,10 +315,9 @@ async fn v1_login_post(mut req: Request) -> tide::Result<tide::Response> {
                         .into(),
                     })?,
                 );
-                // auth = Some(session.auth.with(id, id).first().get()?.expect("can't re-get just-added entity"));
             }
         }
-        ct => {
+        ctype => {
             if let Some(auth) = auth.as_mut() {
                 if let Some(user_id) = auth.pending_user {
                     let user = realm
@@ -321,7 +326,8 @@ async fn v1_login_post(mut req: Request) -> tide::Result<tide::Response> {
                         .get()?
                         .ok_or(UIDCError::Abort("session auth refers to nonexistent user"))?;
 
-                    let verification = user.verify_challenge_by_type(ct, body.challenge.as_bytes());
+                    let verification =
+                        user.verify_challenge_by_type(ctype, body.challenge.as_bytes());
 
                     match verification {
                         Ok(true) => {

+ 4 - 2
src/user.rs

@@ -44,11 +44,13 @@ pub trait UserExt {
         challenge_type: schema::AuthChallengeType,
         response: &[u8],
     ) -> Result<bool, UIDCError> {
-        let ct = challenge_type.into();
         let challenge = self
             .stored_user()
             .auth
-            .with(schema::AuthChallenge::ChallengeType, &ct)
+            .with(
+                schema::AuthChallenge::ChallengeType,
+                challenge_type.clone().into_serialized(),
+            )
             .first()
             .get()?
             .ok_or(UserError::NoSuchChallenge)?;

+ 5 - 0
tmpl/id_v1_login.tmpl

@@ -40,6 +40,11 @@
                         </tr>
                     </table>
                 </form>
+                <ul>
+                    {{ #if show_gh_login }}
+                        <li><a href="{{ gh_login_url }}">Log in with GitHub</a></li>
+                    {{ /if }}
+                </ul>
             </div>
             <div class="footer">
                 Copyright &copy; Kestrel 2024. Released under the terms of the 4-clause BSD license.